Sync from GitHub 33dc1e2
Browse files- README.md +12 -2
- aef.js +374 -0
- app.js +182 -35
- build_index.py +121 -9
- download.js +93 -0
- index.html +36 -4
- viirs.js +46 -0
- weather.js +124 -0
- zstd.js +13 -0
README.md
CHANGED
|
@@ -30,11 +30,21 @@ panel: dataset
|
|
| 30 |
-> app.js: MapLibre globe; cursor shows the cell and whether it has a sample
|
| 31 |
click a cell -> its footprint rows (page index) -> the image file on Hugging Face (one row group per sample)
|
| 32 |
-> samples.js: that row group's thumbnail column -> panel, with acquisition time, cloud cover or orbit, product
|
| 33 |
-
the shown 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
|
|
@@ -54,7 +64,7 @@ in the background: the other datasets' footprints once the first is drawn; after
|
|
| 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) |
|
| 58 |
|
| 59 |
## Reads
|
| 60 |
|
|
|
|
| 30 |
-> app.js: MapLibre globe; cursor shows the cell and whether it has a sample
|
| 31 |
click a cell -> its footprint rows (page index) -> the image file on Hugging Face (one row group per sample)
|
| 32 |
-> samples.js: that row group's thumbnail column -> panel, with acquisition time, cloud cover or orbit, product
|
| 33 |
+
the shown image: Enlarge (full window, zoom and pan) and always on the map over its footprint, opaque: 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 |
+
VIIRS Nighttime Light (viirs.js): no thumbnail column; footprints/VIIRS.parquet gives each tile's zip shard, offset and
|
| 39 |
+
size -> one range request -> the GeoTIFF (zstd: decoder added to geotiff.js, fzstd) -> radiance in inferno, log
|
| 40 |
+
0.1-100 nW/cm²/sr (the Core Viewer's scale) -> panel and map, corners from the GeoTIFF's bounds; no downloads
|
| 41 |
+
AlphaEarth Embeddings (aef.js): footprints/AEF.parquet maps each Core-S2L2A cell to the 81.9 km COG tiles under its
|
| 42 |
+
window, footprints/AEF-tiles.parquet each tile to its file per year (tge-labs/aef on source.coop; one button per year,
|
| 43 |
+
the cell's Sentinel-2 year first) -> PCA of the 64 bands in two steps: the whole 160 m level in one request (~10 MB)
|
| 44 |
+
-> preview (~4.5 s), an exact PCA at 160 m; the 8 bands that best predict the components, at 10 m -> a PCA
|
| 45 |
+
proxy at 10 m (~15 s). A cell's window is joined from every tile it touches (24.6% need two to four). The COGs are
|
| 46 |
+
stored south-up: origin and row direction from each file. View menu: single bands at 10 m. Downloads: the shown
|
| 47 |
+
image (.png), the shown band or all 64 at 10 m (GeoTIFF written in the browser), Python (rasterio.merge)
|
| 48 |
Downloads (download.js): the shown band as stored (.tif); every band, the thumbnail and metadata.json (.zip); a
|
| 49 |
Python snippet that reads the same row group from Hugging Face
|
| 50 |
in the background: the other datasets' footprints once the first is drawn; after a click, the same cell's samples
|
|
|
|
| 64 |
| `parquet.js` | range requests for hyparquet, with bytes and time counted; each file's footer read once |
|
| 65 |
| `tiles.js`, `labels.js`, `places.json` | copied from the Climate Globe: cells and tiles, place names |
|
| 66 |
| `build_index.py` | Core `metadata.parquet` -> `footprints/<name>.parquet` and `footprints/datasets.json` |
|
| 67 |
+
| `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, VIIRS 21.5 MB, AEF 14.3 MB, AEF-tiles 1.8 MB) |
|
| 68 |
|
| 69 |
## Reads
|
| 70 |
|
aef.js
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// AlphaEarth embeddings (tge-labs/aef on source.coop): a cell's window of its yearly COG tiles, shown as a PCA of the
|
| 2 |
+
// 64 bands (fast: a coarse PCA first, then the high-res image from a few bands), or one band.
|
| 3 |
+
//
|
| 4 |
+
// footprint row (tile_row, tile_rows: lists, one entry per tile under the cell's 10,680 m window, its own first)
|
| 5 |
+
// -> footprints/AEF-tiles.parquet, each tile's rows (page index) -> the year's COGs
|
| 6 |
+
// each tile gives the part of the window it covers; the parts are joined (a zone's tiles share one 81,920 m grid,
|
| 7 |
+
// a whole number of pixels at every level, so they fit exactly); 24.6% of cells need more than one tile
|
| 8 |
+
// PCA, in two steps (each source.coop request waits ~0.7 s and few run at once, so requests set the time):
|
| 9 |
+
// 1. preview: each tile's whole 160 m level, all 64 bands in one request (~10 MB; the level's tiles are stored
|
| 10 |
+
// together) -> the window (67 px) -> PCA (three leading components) -> shown at once (onPreview)
|
| 11 |
+
// the level is kept: other cells of the same tile and year skip this read
|
| 12 |
+
// 2. high-res: the 8 bands that best predict the three components (forward selection, least squares on the
|
| 13 |
+
// 160 m pixels; R² 0.95-0.98 against the full 64-band PCA, measured at 40 m for three places) -> those bands at
|
| 14 |
+
// 10 m (geotiff.js, only the tiles under the window) -> the fitted mapping -> 1,068 px
|
| 15 |
+
// geotiff.js reads in 1 MB blocks: the COG's headers (one per level) in one request, a band's adjacent tiles in one
|
| 16 |
+
// one band: that band at 10 m, grey, 2-98% stretch; downloads: the joined window's bands at 10 m (download.js)
|
| 17 |
+
// The COGs are stored south-up (row 0 at the tile's southern edge): the origin and the row direction come from
|
| 18 |
+
// each file's own transform, and rows are flipped so north is up.
|
| 19 |
+
// Pixels no tile covers (a zone edge, the coast) and no-data (-128) are transparent. PCA colours are the cell's
|
| 20 |
+
// own: the same colour means similar embeddings within a cell, not across cells.
|
| 21 |
+
|
| 22 |
+
import { fromCustomClient, getDecoder } from 'https://cdn.jsdelivr.net/npm/geotiff@2.1.3/+esm'
|
| 23 |
+
import proj4 from 'https://cdn.jsdelivr.net/npm/proj4@2.12.1/+esm'
|
| 24 |
+
import { compressors, newStats, openFile, parquetRead } from './parquet.js'
|
| 25 |
+
import './zstd.js'
|
| 26 |
+
|
| 27 |
+
const HALF = 5340, NODATA = -128, FIT = 50_000, OVERVIEW = 4, HIGH = 0, CHOSEN = 8 // levels: 4 = 160 m, 0 = 10 m
|
| 28 |
+
|
| 29 |
+
/** The views offered: the PCA (default), then single bands. */
|
| 30 |
+
export function aefViews() {
|
| 31 |
+
return [{ id: 'pca', label: 'PCA proxy of 64 bands, 10 m' }, // the 160 m PCA, predicted from 8 bands
|
| 32 |
+
...Array.from({ length: 64 }, (_, b) => ({ id: `band:${b}`, label: `Band A${String(b).padStart(2, '0')}, 10 m` }))]
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
/** A geotiff.js client that counts what it reads into `stats`. */
|
| 36 |
+
function countingClient(url, stats) {
|
| 37 |
+
return {
|
| 38 |
+
async request({ headers, signal } = {}) {
|
| 39 |
+
const r = await fetch(url, { headers, signal }), data = await r.arrayBuffer()
|
| 40 |
+
stats.bytes += data.byteLength; stats.requests += 1
|
| 41 |
+
return { ok: r.ok, status: r.status, getHeader: name => r.headers.get(name), getData: async () => data }
|
| 42 |
+
},
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/** One tile's rows (one per year): {year, epsg, west, north, path}. */
|
| 47 |
+
async function tileRows(info, start, count, stats) {
|
| 48 |
+
const url = new URL(`footprints/${info.tiles}`, location.href).href.split('?')[0] + location.search
|
| 49 |
+
const { file, metadata } = await openFile(url, stats)
|
| 50 |
+
let rows = []
|
| 51 |
+
await parquetRead({ file, metadata, compressors, useOffsetIndex: true, rowStart: start, rowEnd: start + count,
|
| 52 |
+
rowFormat: 'object', onComplete(r) { rows = r } })
|
| 53 |
+
return rows
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const listOf = v => (Array.isArray(v) || ArrayBuffer.isView(v) ? [...v].map(Number) : [Number(v)])
|
| 57 |
+
|
| 58 |
+
/** The cell in its UTM zone, and its tiles' files in the sample's year: {epsg, cx, cy, corners, urls}. */
|
| 59 |
+
export async function aefCell(info, sample, stats = newStats()) {
|
| 60 |
+
const starts = listOf(sample.tile_row), counts = listOf(sample.tile_rows)
|
| 61 |
+
const rows = await Promise.all(starts.map((s, k) => tileRows(info, s, counts[k], stats)))
|
| 62 |
+
const tiles = rows.map(r => r.find(x => Number(x.year) === sample.year)).filter(Boolean)
|
| 63 |
+
if (!tiles.length) throw new Error(`no AlphaEarth tile for ${sample.year}`)
|
| 64 |
+
const epsg = Number(tiles[0].epsg)
|
| 65 |
+
const utm = proj4(`+proj=utm +zone=${epsg % 100}${epsg > 32700 ? ' +south' : ''} +datum=WGS84 +units=m +no_defs`)
|
| 66 |
+
const [cx, cy] = utm.forward([sample.centre_lon, sample.centre_lat])
|
| 67 |
+
const corners = [[cx - HALF, cy + HALF], [cx + HALF, cy + HALF], [cx + HALF, cy - HALF], [cx - HALF, cy - HALF]]
|
| 68 |
+
.map(q => utm.inverse(q))
|
| 69 |
+
return { epsg, cx, cy, corners, urls: tiles.map(x => `${info.files}${x.path}`) }
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
const opened = new Map() // COG url -> {tiff, left, top, dy}
|
| 73 |
+
|
| 74 |
+
/** A COG, opened once (its headers in one 1 MB block), with its origin and row direction. */
|
| 75 |
+
function openCog(url, stats) {
|
| 76 |
+
if (!opened.has(url)) {
|
| 77 |
+
opened.set(url, (async () => {
|
| 78 |
+
const tiff = await fromCustomClient(countingClient(url, stats), { blockSize: 2 ** 20 })
|
| 79 |
+
const [, , , left, , dy, , top] = (await tiff.getImage(0)).fileDirectory.ModelTransformation // dy > 0: south-up
|
| 80 |
+
return { url, tiff, left, top, dy }
|
| 81 |
+
})().catch(e => { opened.delete(url); throw e }))
|
| 82 |
+
}
|
| 83 |
+
return opened.get(url)
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/** Every tile's part of the window, joined: one Int8Array per band (NODATA where no tile covers). */
|
| 87 |
+
function join(parts, size) {
|
| 88 |
+
return parts[0].map((first, b) => {
|
| 89 |
+
const out = Int8Array.from(first)
|
| 90 |
+
for (const part of parts.slice(1)) {
|
| 91 |
+
const band = part[b]
|
| 92 |
+
for (let i = 0; i < out.length; i++) if (out[i] === NODATA) out[i] = band[i]
|
| 93 |
+
}
|
| 94 |
+
return out
|
| 95 |
+
})
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/** Bands of the cell's window at one level, read from every tile and joined: {bands, size, west, north, res}. */
|
| 99 |
+
export async function aefWindow(info, sample, level, samples, stats = newStats()) {
|
| 100 |
+
const cell = await aefCell(info, sample, stats), cogs = await Promise.all(cell.urls.map(u => openCog(u, stats)))
|
| 101 |
+
const k = 2 ** level, size = Math.round(2 * HALF / (10 * k)), res = 10 * k
|
| 102 |
+
const parts = await Promise.all(cogs.map(async c => {
|
| 103 |
+
const image = await c.tiff.getImage(level), w = windowAt(c.left, c.top, c.dy, level, cell.cx, cell.cy)
|
| 104 |
+
return readWindow(image, w, samples, stats, c.dy > 0)
|
| 105 |
+
}))
|
| 106 |
+
const w = windowAt(cogs[0].left, cogs[0].top, cogs[0].dy, level, cell.cx, cell.cy)
|
| 107 |
+
return { bands: join(parts.filter(Boolean), size), size, res, west: w.west, north: w.north, cell }
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
/** One view of the cell's window in the sample's year: {src, size, values (one band: for the cursor), corners, stats}.
|
| 111 |
+
* For the PCA, onPreview(view) gets the 160 m image as soon as it is drawn. */
|
| 112 |
+
export async function renderAef(info, sample, viewId, stats = newStats(), onPreview = () => {}) {
|
| 113 |
+
const cell = await aefCell(info, sample, stats)
|
| 114 |
+
const done = async (rgba, size, values = null) => {
|
| 115 |
+
const src = await png(rgba, size)
|
| 116 |
+
stats.read = performance.now() - stats.t0
|
| 117 |
+
return { src, size, values, corners: cell.corners, stats }
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
if (viewId.startsWith('band:')) {
|
| 121 |
+
const window = await aefWindow(info, sample, 0, [Number(viewId.split(':')[1])], stats), { bands: [band], size } = window
|
| 122 |
+
const values = { width: size, values: Float32Array.from(band, v => (v === NODATA ? NaN : v)), unit: '' }
|
| 123 |
+
return { ...(await done(greyRgba(band, size), size, values)), window } // window: for the band's GeoTIFF
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
stats.phase = 'Overview, 160 m'
|
| 127 |
+
const cogs = await Promise.all(cell.urls.map(u => openCog(u, stats)))
|
| 128 |
+
const size8 = Math.round(2 * HALF / (10 * 2 ** OVERVIEW))
|
| 129 |
+
const coarse = join(await Promise.all(cogs.map(async c => {
|
| 130 |
+
const overview = await overviewOf(c, stats), w = windowAt(c.left, c.top, c.dy, OVERVIEW, cell.cx, cell.cy)
|
| 131 |
+
return overview.bands.map(band => cut(band, overview.width, overview.height, w, c.dy > 0))
|
| 132 |
+
})), size8)
|
| 133 |
+
const pca = fitPca(coarse, size8)
|
| 134 |
+
if (!pca) return done(new Uint8ClampedArray(size8 * size8 * 4), size8)
|
| 135 |
+
onPreview({ ...(await done(rgbOf(pca.scores, pca.valid, size8), size8)), label: 'PCA of 64 bands, 160 m' })
|
| 136 |
+
|
| 137 |
+
stats.phase = `Loading high-res: ${CHOSEN} bands at 10 m`
|
| 138 |
+
const { chosen, weights } = selectBands(coarse, pca, CHOSEN)
|
| 139 |
+
const { bands: fine, size } = await aefWindow(info, sample, HIGH, chosen, stats)
|
| 140 |
+
const n = size * size, valid = [], scores = [0, 1, 2].map(() => new Float32Array(n))
|
| 141 |
+
for (let i = 0; i < n; i++) {
|
| 142 |
+
if (fine[0][i] === NODATA) continue
|
| 143 |
+
valid.push(i)
|
| 144 |
+
for (let k = 0; k < 3; k++) {
|
| 145 |
+
let s = weights[chosen.length][k] // intercept
|
| 146 |
+
for (let j = 0; j < chosen.length; j++) s += fine[j][i] * weights[j][k]
|
| 147 |
+
scores[k][i] = s
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
return done(rgbOf(scores, valid, size), size)
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
/** The cell's window on one level: its size, its position in that level's pixels (rows counted as stored), and its
|
| 154 |
+
* west and north edges in metres. */
|
| 155 |
+
function windowAt(left, top, dy, level, cx, cy) {
|
| 156 |
+
const k = 2 ** level, res = 10 * k, size = Math.round(2 * HALF / res)
|
| 157 |
+
const x0 = Math.round((cx - HALF - left) / res)
|
| 158 |
+
const y0 = Math.round(Math.min((cy + HALF - top) / (dy * k), (cy - HALF - top) / (dy * k)))
|
| 159 |
+
const yEdges = [top + y0 * dy * k, top + (y0 + size) * dy * k]
|
| 160 |
+
return { level, size, x0, y0, west: left + x0 * res, north: Math.max(...yEdges) }
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
const overviews = new Map() // COG url -> its 160 m level, all bands (six kept: ~16 MB each)
|
| 164 |
+
|
| 165 |
+
/** The preview level of a COG (160 m), every band, from one request over the level's tiles (stored together), kept. */
|
| 166 |
+
function overviewOf({ url, tiff }, stats) {
|
| 167 |
+
if (!overviews.has(url)) {
|
| 168 |
+
overviews.set(url, (async () => {
|
| 169 |
+
const image = await tiff.getImage(OVERVIEW), fd = image.fileDirectory, offsets = fd.TileOffsets, counts = fd.TileByteCounts
|
| 170 |
+
const start = Math.min(...offsets), end = Math.max(...offsets.map((o, i) => o + counts[i]))
|
| 171 |
+
stats.expected = Math.max(stats.expected ?? 0, stats.bytes) + (end - start)
|
| 172 |
+
const r = await fetch(url, { headers: { Range: `bytes=${start}-${end - 1}` } })
|
| 173 |
+
if (r.status !== 206) throw new Error(`AlphaEarth overview: HTTP ${r.status}`)
|
| 174 |
+
const data = await r.arrayBuffer()
|
| 175 |
+
stats.bytes += data.byteLength; stats.requests += 1
|
| 176 |
+
const decoder = await getDecoder(fd), W = image.getWidth(), H = image.getHeight(), T = fd.TileWidth
|
| 177 |
+
const across = Math.ceil(W / T), down = Math.ceil(H / fd.TileLength), per = across * down
|
| 178 |
+
const bands = []
|
| 179 |
+
for (let b = 0; b < image.getSamplesPerPixel(); b++) {
|
| 180 |
+
const band = new Int8Array(W * H)
|
| 181 |
+
for (let r = 0; r < down; r++) {
|
| 182 |
+
for (let q = 0; q < across; q++) {
|
| 183 |
+
const t = b * per + r * across + q
|
| 184 |
+
const raw = new Int8Array(await decoder.decode(fd, data.slice(offsets[t] - start, offsets[t] - start + counts[t])))
|
| 185 |
+
for (let y = 0; y < T && r * T + y < H; y++) {
|
| 186 |
+
const w = Math.min(T, W - q * T)
|
| 187 |
+
band.set(raw.subarray(y * T, y * T + w), (r * T + y) * W + q * T)
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
bands.push(band)
|
| 192 |
+
}
|
| 193 |
+
if (overviews.size > 6) overviews.delete(overviews.keys().next().value)
|
| 194 |
+
return { width: W, height: H, bands }
|
| 195 |
+
})().catch(e => { overviews.delete(url); throw e }))
|
| 196 |
+
}
|
| 197 |
+
return overviews.get(url)
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
/** The window's pixels of some bands in one COG, read through geotiff.js (only the tiles under it), north up;
|
| 201 |
+
* null when the COG holds none of the window. */
|
| 202 |
+
async function readWindow(image, w, samples, stats, flip) {
|
| 203 |
+
const W = image.getWidth(), H = image.getHeight()
|
| 204 |
+
const box = [Math.max(w.x0, 0), Math.max(w.y0, 0), Math.min(w.x0 + w.size, W), Math.min(w.y0 + w.size, H)]
|
| 205 |
+
if (box[2] <= box[0] || box[3] <= box[1]) return null // this tile holds none of the window
|
| 206 |
+
stats.expected = Math.max(stats.expected ?? 0, stats.bytes) + expectedBytes(image, box, samples)
|
| 207 |
+
const parts = await image.readRasters({ window: box, samples, interleave: false })
|
| 208 |
+
return parts.map(part => place(part, box, w, flip))
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
/** Compressed bytes of the tiles under the window, for the bands read (planar COG: tiles band by band). */
|
| 212 |
+
function expectedBytes(image, [a, b, c, d], samples) {
|
| 213 |
+
const fd = image.fileDirectory, T = fd.TileWidth, across = Math.ceil(image.getWidth() / T)
|
| 214 |
+
const per = across * Math.ceil(image.getHeight() / fd.TileLength)
|
| 215 |
+
let sum = 0
|
| 216 |
+
for (const s of samples) {
|
| 217 |
+
for (let r = Math.floor(b / T); r <= Math.floor((d - 1) / T); r++) {
|
| 218 |
+
for (let q = Math.floor(a / T); q <= Math.floor((c - 1) / T); q++) sum += fd.TileByteCounts[s * per + r * across + q]
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
return sum
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
/** A read box (part of the window inside the image) placed into the full window, north up, NODATA elsewhere. */
|
| 225 |
+
function place(part, [a, b, c, d], w, flip) {
|
| 226 |
+
const out = new Int8Array(w.size * w.size).fill(NODATA), width = c - a
|
| 227 |
+
for (let y = b; y < d; y++) {
|
| 228 |
+
const row = flip ? w.size - 1 - (y - w.y0) : y - w.y0
|
| 229 |
+
out.set(part.subarray((y - b) * width, (y - b + 1) * width), row * w.size + (a - w.x0))
|
| 230 |
+
}
|
| 231 |
+
return out
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
/** The window cut from a whole level held in memory. */
|
| 235 |
+
function cut(band, W, H, w, flip) {
|
| 236 |
+
const box = [Math.max(w.x0, 0), Math.max(w.y0, 0), Math.min(w.x0 + w.size, W), Math.min(w.y0 + w.size, H)]
|
| 237 |
+
const width = box[2] - box[0], part = new Int8Array(Math.max(width, 0) * Math.max(box[3] - box[1], 0))
|
| 238 |
+
for (let y = box[1]; y < box[3]; y++) part.set(band.subarray(y * W + box[0], y * W + box[2]), (y - box[1]) * width)
|
| 239 |
+
return place(part, box, w, flip)
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
/** PCA of the window's bands: {mean, components (3 x 64), scores (3 arrays), valid (pixel indices), fit (indices)}. */
|
| 243 |
+
function fitPca(bands, size) {
|
| 244 |
+
const n = size * size, B = bands.length, valid = []
|
| 245 |
+
for (let i = 0; i < n; i++) if (bands[0][i] !== NODATA) valid.push(i)
|
| 246 |
+
if (valid.length < 2) return null
|
| 247 |
+
const step = Math.max(1, Math.floor(valid.length / FIT)), fit = valid.filter((_, k) => k % step === 0)
|
| 248 |
+
const mean = new Float64Array(B), cov = new Float64Array(B * B), x = new Float64Array(B)
|
| 249 |
+
for (const i of fit) for (let b = 0; b < B; b++) mean[b] += bands[b][i] / fit.length
|
| 250 |
+
for (const i of fit) {
|
| 251 |
+
for (let b = 0; b < B; b++) x[b] = bands[b][i] - mean[b]
|
| 252 |
+
for (let b = 0; b < B; b++) for (let c = b; c < B; c++) cov[b * B + c] += x[b] * x[c]
|
| 253 |
+
}
|
| 254 |
+
for (let b = 0; b < B; b++) for (let c = 0; c < b; c++) cov[b * B + c] = cov[c * B + b]
|
| 255 |
+
const components = leading(cov, B, 3), scores = components.map(() => new Float32Array(n))
|
| 256 |
+
for (const i of valid) {
|
| 257 |
+
for (let k = 0; k < 3; k++) {
|
| 258 |
+
let s = 0
|
| 259 |
+
for (let b = 0; b < B; b++) s += (bands[b][i] - mean[b]) * components[k][b]
|
| 260 |
+
scores[k][i] = s
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
return { mean, components, scores, valid, fit }
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
/** The `count` bands that best predict the three components (forward selection on R², least squares with an
|
| 267 |
+
* intercept, from the normal equations), and the weights: rows per chosen band, then the intercept; 3 columns. */
|
| 268 |
+
function selectBands(bands, pca, count) {
|
| 269 |
+
const B = bands.length, I = B, pixels = pca.fit, Y = pca.scores
|
| 270 |
+
const G = new Float64Array((B + 1) * (B + 1)), XY = Array.from({ length: B + 1 }, () => new Float64Array(3))
|
| 271 |
+
const value = (b, i) => (b === I ? 1 : bands[b][i])
|
| 272 |
+
for (const i of pixels) {
|
| 273 |
+
for (let b = 0; b <= B; b++) {
|
| 274 |
+
const vb = value(b, i)
|
| 275 |
+
for (let c = b; c <= B; c++) G[b * (B + 1) + c] += vb * value(c, i)
|
| 276 |
+
for (let k = 0; k < 3; k++) XY[b][k] += vb * Y[k][i]
|
| 277 |
+
}
|
| 278 |
+
}
|
| 279 |
+
for (let b = 0; b <= B; b++) for (let c = 0; c < b; c++) G[b * (B + 1) + c] = G[c * (B + 1) + b]
|
| 280 |
+
const explained = set => { // sum over components of the fitted values' sum of squares
|
| 281 |
+
const w = solve(set.map(b => set.map(c => G[b * (B + 1) + c])), set.map(b => [...XY[b]]))
|
| 282 |
+
if (!w) return [-Infinity, null]
|
| 283 |
+
let s = 0
|
| 284 |
+
for (let j = 0; j < set.length; j++) for (let k = 0; k < 3; k++) s += w[j][k] * XY[set[j]][k]
|
| 285 |
+
return [s, w]
|
| 286 |
+
}
|
| 287 |
+
const chosen = []
|
| 288 |
+
for (let step = 0; step < count; step++) {
|
| 289 |
+
let best = -1, bestScore = -Infinity
|
| 290 |
+
for (let b = 0; b < B; b++) {
|
| 291 |
+
if (chosen.includes(b)) continue
|
| 292 |
+
const [s] = explained([...chosen, b, I])
|
| 293 |
+
if (s > bestScore) { bestScore = s; best = b }
|
| 294 |
+
}
|
| 295 |
+
chosen.push(best)
|
| 296 |
+
}
|
| 297 |
+
return { chosen, weights: explained([...chosen, I])[1] }
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
/** Solves A W = R (A: m x m, R: m x 3) by Gaussian elimination with partial pivoting; null if singular. */
|
| 301 |
+
function solve(A, R) {
|
| 302 |
+
const m = A.length, a = A.map((row, i) => [...row, ...R[i]])
|
| 303 |
+
for (let c = 0; c < m; c++) {
|
| 304 |
+
let p = c
|
| 305 |
+
for (let r = c + 1; r < m; r++) if (Math.abs(a[r][c]) > Math.abs(a[p][c])) p = r
|
| 306 |
+
if (Math.abs(a[p][c]) < 1e-9) return null
|
| 307 |
+
;[a[c], a[p]] = [a[p], a[c]]
|
| 308 |
+
for (let r = 0; r < m; r++) {
|
| 309 |
+
if (r === c) continue
|
| 310 |
+
const f = a[r][c] / a[c][c]
|
| 311 |
+
for (let k = c; k < m + 3; k++) a[r][k] -= f * a[c][k]
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
return a.map((row, i) => row.slice(m).map(v => v / a[i][i]))
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
/** 2nd and 98th percentile of a sample of values. */
|
| 318 |
+
function range(values) {
|
| 319 |
+
const v = Float64Array.from(values).sort()
|
| 320 |
+
if (!v.length) return [0, 1]
|
| 321 |
+
const lo = v[Math.floor(v.length * 0.02)], hi = v[Math.floor(v.length * 0.98)]
|
| 322 |
+
return hi > lo ? [lo, hi] : [lo, lo + 1]
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
/** Three component images as RGB, each stretched 2-98% over a sample of the valid pixels; the rest transparent. */
|
| 326 |
+
function rgbOf(scores, valid, size) {
|
| 327 |
+
const out = new Uint8ClampedArray(size * size * 4), step = Math.max(1, Math.floor(valid.length / FIT))
|
| 328 |
+
const ranges = scores.map(s => range(valid.filter((_, k) => k % step === 0).map(i => s[i])))
|
| 329 |
+
for (const i of valid) {
|
| 330 |
+
for (let k = 0; k < 3; k++) out[i * 4 + k] = 255 * Math.min(Math.max((scores[k][i] - ranges[k][0]) / (ranges[k][1] - ranges[k][0]), 0), 1)
|
| 331 |
+
out[i * 4 + 3] = 255
|
| 332 |
+
}
|
| 333 |
+
return out
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
function greyRgba(band, size) {
|
| 337 |
+
const out = new Uint8ClampedArray(size * size * 4), picked = []
|
| 338 |
+
for (let i = 0; i < band.length; i += Math.max(1, Math.floor(band.length / FIT))) if (band[i] !== NODATA) picked.push(band[i])
|
| 339 |
+
const [lo, hi] = range(picked)
|
| 340 |
+
for (let i = 0; i < band.length; i++) {
|
| 341 |
+
if (band[i] === NODATA) continue
|
| 342 |
+
const g = 255 * Math.min(Math.max((band[i] - lo) / (hi - lo), 0), 1)
|
| 343 |
+
out[i * 4] = g; out[i * 4 + 1] = g; out[i * 4 + 2] = g; out[i * 4 + 3] = 255
|
| 344 |
+
}
|
| 345 |
+
return out
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
async function png(rgba, size) {
|
| 349 |
+
const canvas = new OffscreenCanvas(size, size)
|
| 350 |
+
canvas.getContext('2d').putImageData(new ImageData(rgba, size, size), 0, 0)
|
| 351 |
+
return URL.createObjectURL(await canvas.convertToBlob({ type: 'image/png' }))
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
/** The `count` leading eigenvectors of a symmetric B x B matrix: power iteration, deflating after each. Signs are
|
| 355 |
+
* fixed (largest loading positive) so the same data gives the same colours. */
|
| 356 |
+
function leading(cov, B, count) {
|
| 357 |
+
const m = Float64Array.from(cov), out = []
|
| 358 |
+
for (let k = 0; k < count; k++) {
|
| 359 |
+
let v = Float64Array.from({ length: B }, (_, b) => 1 + b / B), lambda = 0
|
| 360 |
+
for (let it = 0; it < 200; it++) {
|
| 361 |
+
const w = new Float64Array(B)
|
| 362 |
+
for (let b = 0; b < B; b++) { let s = 0; for (let c = 0; c < B; c++) s += m[b * B + c] * v[c]; w[b] = s }
|
| 363 |
+
const norm = Math.hypot(...w)
|
| 364 |
+
if (!norm) break
|
| 365 |
+
lambda = norm; v = w.map(e => e / norm)
|
| 366 |
+
}
|
| 367 |
+
let big = 0
|
| 368 |
+
for (let b = 1; b < B; b++) if (Math.abs(v[b]) > Math.abs(v[big])) big = b
|
| 369 |
+
if (v[big] < 0) v = v.map(e => -e)
|
| 370 |
+
for (let b = 0; b < B; b++) for (let c = 0; c < B; c++) m[b * B + c] -= lambda * v[b] * v[c]
|
| 371 |
+
out.push(v)
|
| 372 |
+
}
|
| 373 |
+
return out
|
| 374 |
+
}
|
app.js
CHANGED
|
@@ -2,11 +2,14 @@
|
|
| 2 |
//
|
| 3 |
// dataset (panel) -> footprints/<name>.parquet, two position columns (footprints.js) -> mask on the cell array
|
| 4 |
// -> coverage tiles drawn in the browser (tiles.js) -> globe
|
|
|
|
|
|
|
| 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)
|
|
|
|
| 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'
|
|
@@ -17,11 +20,15 @@ import { placeLabels } from './labels.js'
|
|
| 17 |
import { imageUrl, loadThumbnail } from './samples.js'
|
| 18 |
import { bandNames, render, views } from './bands.js'
|
| 19 |
import { newStats } from './parquet.js'
|
| 20 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
import { cellAt, cellIndex, renderTile } from './tiles.js'
|
| 22 |
|
| 23 |
const $ = id => document.getElementById(id)
|
| 24 |
-
const COLOURS = { S2L2A: [57, 181, 74], S2L1C: [0, 113, 188], S1RTC: [241, 90, 36], DEM: [180, 140, 90] }
|
| 25 |
|
| 26 |
const state = { name: null, info: null, datasets: null, coverage: null, version: 0, loading: 0 }
|
| 27 |
|
|
@@ -42,7 +49,7 @@ maplibregl.addProtocol('mt', async params => {
|
|
| 42 |
})
|
| 43 |
|
| 44 |
const map = new maplibregl.Map({
|
| 45 |
-
container: 'map', center: [12, 22], zoom: innerWidth < 820 ? 1.3 : 2.25, attributionControl: false, maxZoom:
|
| 46 |
style: {
|
| 47 |
version: 8, projection: { type: 'globe' },
|
| 48 |
sky: { 'atmosphere-blend': ['interpolate', ['linear'], ['zoom'], 0, 0.6, 4, 0.3, 7, 0] },
|
|
@@ -204,8 +211,9 @@ async function setDataset(name, datasets) {
|
|
| 204 |
const ticket = ++state.loading, info = datasets[name]
|
| 205 |
state.name = name; state.info = info; state.coverage = null
|
| 206 |
document.querySelector(`input[name=dataset][value="${name}"]`).checked = true
|
| 207 |
-
|
| 208 |
-
$('dataset-link').
|
|
|
|
| 209 |
$('status').textContent = ''
|
| 210 |
startLoader(`${info.label} · footprints of ${info.samples.toLocaleString('en')} samples`)
|
| 211 |
let coverage
|
|
@@ -250,20 +258,39 @@ const thumbs = new Map() // "<dataset>/<part>/<
|
|
| 250 |
|
| 251 |
function samplesOf(name, row, col) {
|
| 252 |
const key = `${name}/${row}/${col}`
|
| 253 |
-
if (!lookups.has(key)) lookups.set(key, samplesAt(name, row, col).catch(e => { lookups.delete(key); throw e }))
|
| 254 |
return lookups.get(key)
|
| 255 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
function thumbnailOf(name, sample) {
|
| 257 |
-
const key = `${name}/${sample
|
| 258 |
-
if (fresh)
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
}
|
| 261 |
|
| 262 |
/** The selected cell in every other dataset, in the panel's order, after the shown one. */
|
| 263 |
async function prefetchCell(cell) {
|
| 264 |
for (const name of Object.keys(state.datasets)) {
|
| 265 |
if (pick.cell !== cell) return // another cell was picked
|
| 266 |
-
if (name === state.name) continue
|
| 267 |
try {
|
| 268 |
const samples = await samplesOf(name, cell.row, cell.col)
|
| 269 |
if (samples.length) await thumbnailOf(name, samples[0])[0]
|
|
@@ -294,7 +321,7 @@ async function pickCell() {
|
|
| 294 |
const ticket = ++pick.ticket, { row, col } = pick.cell, name = state.name
|
| 295 |
pick.samples = []; pick.thumbnail = null; pick.shown = null; pick.viewTicket++
|
| 296 |
showThumbnail(null, 'Looking up the cell…'); showOnMap()
|
| 297 |
-
$('choices').replaceChildren(); $('pick-meta').replaceChildren(); $('pick-stats').textContent = ''
|
| 298 |
let samples
|
| 299 |
try {
|
| 300 |
samples = await samplesOf(name, row, col)
|
|
@@ -308,12 +335,12 @@ async function pickCell() {
|
|
| 308 |
if (samples.length > 1) {
|
| 309 |
$('choices').replaceChildren(...samples.map((s, i) => {
|
| 310 |
const b = document.createElement('button')
|
| 311 |
-
b.textContent = s.timestamp ? when(s.timestamp) : `sample ${i + 1}`
|
| 312 |
b.addEventListener('click', () => choose(i))
|
| 313 |
return b
|
| 314 |
}))
|
| 315 |
}
|
| 316 |
-
choose(0)
|
| 317 |
}
|
| 318 |
|
| 319 |
async function choose(i) {
|
|
@@ -321,17 +348,33 @@ async function choose(i) {
|
|
| 321 |
pick.chosen = i; pick.shown = null; pick.bands = []; pick.views = []; pick.viewTicket++
|
| 322 |
for (const [k, b] of [...$('choices').children].entries()) b.setAttribute('aria-pressed', String(k === i))
|
| 323 |
meta(sample, null, info)
|
| 324 |
-
|
|
|
|
| 325 |
try {
|
| 326 |
-
const [loading, fresh] = thumbnailOf(state.name, sample)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
if (ticket !== pick.ticket) return
|
| 328 |
-
|
| 329 |
-
|
|
|
|
|
|
|
| 330 |
showThumbnail(t.src)
|
| 331 |
showOnMap()
|
| 332 |
meta(sample, t.productId, info)
|
| 333 |
-
|
| 334 |
-
|
|
|
|
|
|
|
| 335 |
(fresh ? '' : ' (loaded before)')
|
| 336 |
prefetchCell(pick.cell)
|
| 337 |
} catch (error) {
|
|
@@ -339,7 +382,21 @@ async function choose(i) {
|
|
| 339 |
}
|
| 340 |
}
|
| 341 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
function showThumbnail(src, note = '') {
|
|
|
|
| 343 |
$('thumb').hidden = !src
|
| 344 |
$('image-actions').hidden = !src
|
| 345 |
$('download-actions').hidden = !src
|
|
@@ -356,28 +413,54 @@ const HALF = 5340
|
|
| 356 |
|
| 357 |
/** Corners of a sample's image, [lon, lat], from top left clockwise (as MapLibre's image source wants them). */
|
| 358 |
function corners(s) {
|
|
|
|
| 359 |
const zone = s.epsg % 100, south = s.epsg > 32700
|
| 360 |
const utm = proj4(`+proj=utm +zone=${zone}${south ? ' +south' : ''} +datum=WGS84 +units=m +no_defs`)
|
| 361 |
const [x, y] = utm.forward([s.centre_lon, s.centre_lat])
|
| 362 |
return [[x - HALF, y + HALF], [x + HALF, y + HALF], [x + HALF, y - HALF], [x - HALF, y - HALF]].map(p => utm.inverse(p))
|
| 363 |
}
|
| 364 |
|
| 365 |
-
/** The shown image over its footprint
|
| 366 |
function showOnMap() {
|
| 367 |
-
const t = pick.shown, on =
|
| 368 |
if (map.getLayer('sample')) map.removeLayer('sample')
|
| 369 |
if (map.getSource('sample')) map.removeSource('sample')
|
| 370 |
if (!on) return
|
| 371 |
map.addSource('sample', { type: 'image', url: t.src, coordinates: corners(t.sample) })
|
| 372 |
map.addLayer({ id: 'sample', type: 'raster', source: 'sample',
|
| 373 |
-
paint: { 'raster-opacity':
|
| 374 |
}
|
| 375 |
-
$('on-map').addEventListener('change', showOnMap)
|
| 376 |
-
$('opacity').addEventListener('input', () => {
|
| 377 |
-
if (map.getLayer('sample')) map.setPaintProperty('sample', 'raster-opacity', Number($('opacity').value) / 100)
|
| 378 |
-
})
|
| 379 |
window.sampleCorners = () => (pick.shown ? corners(pick.shown.sample) : null) // for automated checks
|
| 380 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
// ---- views: the stored thumbnail, composites and single bands ------------------------------------------------
|
| 382 |
// the menu lists the image file's band columns; a chosen view is decoded once and kept (renders)
|
| 383 |
|
|
@@ -394,16 +477,30 @@ async function buildViews(name, info, sample, ticket) {
|
|
| 394 |
} catch { /* no menu: the thumbnail stays */ }
|
| 395 |
}
|
| 396 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
$('view').addEventListener('change', () => showView($('view').value))
|
| 398 |
|
| 399 |
async function showView(id) {
|
| 400 |
const t = pick.thumbnail, ticket = ++pick.viewTicket
|
| 401 |
if (!t) return
|
| 402 |
-
if (id === 'thumbnail') {
|
| 403 |
-
|
|
|
|
|
|
|
|
|
|
| 404 |
$('pick-stats').textContent = `Loading ${view.label}…`
|
| 405 |
const fresh = !renders.has(key), stats = newStats()
|
| 406 |
-
if (fresh)
|
|
|
|
|
|
|
|
|
|
| 407 |
const stop = fresh ? followLoad(`${view.label} · ${pick.cell.name}`, stats) : () => {}
|
| 408 |
try {
|
| 409 |
const r = await renders.get(key).finally(stop)
|
|
@@ -440,6 +537,23 @@ function refreshShown() {
|
|
| 440 |
const shownBand = () => (pick.shown && pick.bands.includes(pick.shown.label) ? pick.shown.label : null)
|
| 441 |
|
| 442 |
function updateDownloads() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
const band = shownBand()
|
| 444 |
$('dl-band').disabled = !band
|
| 445 |
$('dl-band').textContent = band ? `Download ${band} (.tif)` : 'Download band (.tif)'
|
|
@@ -458,17 +572,42 @@ async function downloading(button, job) {
|
|
| 458 |
} finally { button.textContent = label; updateDownloads() }
|
| 459 |
}
|
| 460 |
|
| 461 |
-
|
| 462 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
$('dl-all').addEventListener('click', () => downloading($('dl-all'), async () => {
|
| 464 |
const stats = newStats(), stop = followLoad(`All bands · ${pick.cell.name} · for download`, stats)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
try {
|
| 466 |
return await downloadAll(state.info, state.name, pick.cell.name, pick.thumbnail.sample, pick.thumbnail.src,
|
| 467 |
pick.thumbnail.productId, stats)
|
| 468 |
} finally { stop() }
|
| 469 |
}))
|
| 470 |
$('dl-python').addEventListener('click', async () => {
|
| 471 |
-
const code =
|
|
|
|
|
|
|
| 472 |
window.lastSnippet = code // for automated checks
|
| 473 |
try { await navigator.clipboard.writeText(code); $('pick-stats').textContent = 'Python copied to the clipboard' }
|
| 474 |
catch { window.prompt('Python that reads this sample:', code) }
|
|
@@ -535,7 +674,15 @@ function meta(sample, productId, info) {
|
|
| 535 |
if (sample.cloud_cover !== undefined && sample.cloud_cover !== null) rows.push(['Cloud cover', `${sample.cloud_cover.toFixed(1)}%`])
|
| 536 |
if (sample.orbit_state) rows.push(['Orbit', sample.orbit_state])
|
| 537 |
if (productId) rows.push(['Product', productId])
|
| 538 |
-
|
| 539 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 540 |
$('pick-meta').innerHTML = rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('')
|
| 541 |
}
|
|
|
|
| 2 |
//
|
| 3 |
// dataset (panel) -> footprints/<name>.parquet, two position columns (footprints.js) -> mask on the cell array
|
| 4 |
// -> coverage tiles drawn in the browser (tiles.js) -> globe
|
| 5 |
+
// datasets with a kind have no thumbnail column: VIIRS tiles are read and coloured (viirs.js); AlphaEarth windows
|
| 6 |
+
// are read from the yearly COG and shown as a PCA of the 64 bands (aef.js), one choice button per year
|
| 7 |
// click -> cell -> its samples (footprint rows, page index) -> image file on Hugging Face, one row group,
|
| 8 |
// thumbnail column (samples.js) -> panel
|
| 9 |
// cursor -> cell under it -> outline + name, position, whether it has a sample
|
| 10 |
// the shown image (the stored thumbnail, or a band or composite decoded from the image file, bands.js)
|
| 11 |
+
// -> full window (zoom, pan, value under the cursor) and on the map over its footprint (corners from its UTM centre)
|
| 12 |
+
// the sample's acquisition -> Core-Weather on source.coop, ±24 h and ±30 d (weather.js) -> charts in the panel
|
| 13 |
// -> downloads of the bands as stored (download.js)
|
| 14 |
|
| 15 |
import * as maplibregl from 'https://cdn.jsdelivr.net/npm/maplibre-gl@6.14.0/dist/maplibre-gl.mjs'
|
|
|
|
| 20 |
import { imageUrl, loadThumbnail } from './samples.js'
|
| 21 |
import { bandNames, render, views } from './bands.js'
|
| 22 |
import { newStats } from './parquet.js'
|
| 23 |
+
import { loadWeather, weatherRows } from './weather.js'
|
| 24 |
+
import { loadViirs } from './viirs.js'
|
| 25 |
+
import { aefCell, aefViews, aefWindow, renderAef } from './aef.js'
|
| 26 |
+
import { aefSnippet, downloadAefTiff, downloadAll, downloadBand, downloadImage, downloadViirs, pythonSnippet, viirsSnippet }
|
| 27 |
+
from './download.js'
|
| 28 |
import { cellAt, cellIndex, renderTile } from './tiles.js'
|
| 29 |
|
| 30 |
const $ = id => document.getElementById(id)
|
| 31 |
+
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] }
|
| 32 |
|
| 33 |
const state = { name: null, info: null, datasets: null, coverage: null, version: 0, loading: 0 }
|
| 34 |
|
|
|
|
| 49 |
})
|
| 50 |
|
| 51 |
const map = new maplibregl.Map({
|
| 52 |
+
container: 'map', center: [12, 22], zoom: innerWidth < 820 ? 1.3 : 2.25, attributionControl: false, maxZoom: 13,
|
| 53 |
style: {
|
| 54 |
version: 8, projection: { type: 'globe' },
|
| 55 |
sky: { 'atmosphere-blend': ['interpolate', ['linear'], ['zoom'], 0, 0.6, 4, 0.3, 7, 0] },
|
|
|
|
| 211 |
const ticket = ++state.loading, info = datasets[name]
|
| 212 |
state.name = name; state.info = info; state.coverage = null
|
| 213 |
document.querySelector(`input[name=dataset][value="${name}"]`).checked = true
|
| 214 |
+
const link = info.link ?? `https://huggingface.co/datasets/Major-TOM/Core-${name}`
|
| 215 |
+
$('dataset-link').href = link
|
| 216 |
+
$('dataset-link').textContent = link.replace('https://', '')
|
| 217 |
$('status').textContent = ''
|
| 218 |
startLoader(`${info.label} · footprints of ${info.samples.toLocaleString('en')} samples`)
|
| 219 |
let coverage
|
|
|
|
| 258 |
|
| 259 |
function samplesOf(name, row, col) {
|
| 260 |
const key = `${name}/${row}/${col}`
|
| 261 |
+
if (!lookups.has(key)) lookups.set(key, samplesAt(name, row, col).then(rows => byYear(name, rows)).catch(e => { lookups.delete(key); throw e }))
|
| 262 |
return lookups.get(key)
|
| 263 |
}
|
| 264 |
+
|
| 265 |
+
/** AlphaEarth: the cell's one footprint row as one sample per year (cellYear: its Sentinel-2 year, the default). */
|
| 266 |
+
function byYear(name, rows) {
|
| 267 |
+
const info = state.datasets[name]
|
| 268 |
+
if (info.kind !== 'aef' || !rows.length) return rows
|
| 269 |
+
const [first, last] = info.years
|
| 270 |
+
return Array.from({ length: last - first + 1 }, (_, k) => ({ ...rows[0], year: first + k, cellYear: rows[0].year }))
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
/** A sample's key, unique within its dataset. */
|
| 274 |
+
const sampleKey = s => (s.part !== undefined ? `${s.part}/${s.parquet_row}` : s.shard ? `${s.shard}/${s.offset}`
|
| 275 |
+
: `${s.grid_row_u}/${s.grid_col_r}/${s.year}`)
|
| 276 |
+
|
| 277 |
+
/** The first image of a sample, read once: [promise, fresh, stats]. Thumbnail (Core), tile (VIIRS) or PCA (AlphaEarth). */
|
| 278 |
function thumbnailOf(name, sample) {
|
| 279 |
+
const info = state.datasets[name], key = `${name}/${sampleKey(sample)}`, fresh = !thumbs.has(key)
|
| 280 |
+
if (fresh) {
|
| 281 |
+
const stats = newStats()
|
| 282 |
+
const load = info.kind === 'viirs' ? loadViirs(info, sample, stats) : info.kind === 'aef' ? renderAef(info, sample, 'pca', stats, view => stats.onPreview?.(view))
|
| 283 |
+
: loadThumbnail(info, sample)
|
| 284 |
+
thumbs.set(key, { promise: load.catch(e => { thumbs.delete(key); throw e }), stats })
|
| 285 |
+
}
|
| 286 |
+
return [thumbs.get(key).promise, fresh, thumbs.get(key).stats]
|
| 287 |
}
|
| 288 |
|
| 289 |
/** The selected cell in every other dataset, in the panel's order, after the shown one. */
|
| 290 |
async function prefetchCell(cell) {
|
| 291 |
for (const name of Object.keys(state.datasets)) {
|
| 292 |
if (pick.cell !== cell) return // another cell was picked
|
| 293 |
+
if (name === state.name || state.datasets[name].kind === 'aef') continue // AlphaEarth: tens of MB, only on request
|
| 294 |
try {
|
| 295 |
const samples = await samplesOf(name, cell.row, cell.col)
|
| 296 |
if (samples.length) await thumbnailOf(name, samples[0])[0]
|
|
|
|
| 321 |
const ticket = ++pick.ticket, { row, col } = pick.cell, name = state.name
|
| 322 |
pick.samples = []; pick.thumbnail = null; pick.shown = null; pick.viewTicket++
|
| 323 |
showThumbnail(null, 'Looking up the cell…'); showOnMap()
|
| 324 |
+
$('choices').replaceChildren(); $('pick-meta').replaceChildren(); $('pick-stats').textContent = ''; $('weather').hidden = true
|
| 325 |
let samples
|
| 326 |
try {
|
| 327 |
samples = await samplesOf(name, row, col)
|
|
|
|
| 335 |
if (samples.length > 1) {
|
| 336 |
$('choices').replaceChildren(...samples.map((s, i) => {
|
| 337 |
const b = document.createElement('button')
|
| 338 |
+
b.textContent = s.timestamp ? when(s.timestamp) : s.year ? String(s.year) : `sample ${i + 1}`
|
| 339 |
b.addEventListener('click', () => choose(i))
|
| 340 |
return b
|
| 341 |
}))
|
| 342 |
}
|
| 343 |
+
choose(Math.max(0, samples.findIndex(s => s.cellYear !== undefined && s.year === s.cellYear)))
|
| 344 |
}
|
| 345 |
|
| 346 |
async function choose(i) {
|
|
|
|
| 348 |
pick.chosen = i; pick.shown = null; pick.bands = []; pick.views = []; pick.viewTicket++
|
| 349 |
for (const [k, b] of [...$('choices').children].entries()) b.setAttribute('aria-pressed', String(k === i))
|
| 350 |
meta(sample, null, info)
|
| 351 |
+
showWeather(sample, ticket)
|
| 352 |
+
showThumbnail(null, info.kind === 'aef' ? 'Reading the 64 bands…' : 'Loading the thumbnail…')
|
| 353 |
try {
|
| 354 |
+
const [loading, fresh, stats] = thumbnailOf(state.name, sample)
|
| 355 |
+
const stop = fresh && info.kind === 'aef' ? followLoad(`AlphaEarth ${sample.year} · ${pick.cell.name} · 64 bands`, stats) : () => {}
|
| 356 |
+
let unbadge = () => {}
|
| 357 |
+
stats.onPreview = view => { // AlphaEarth: the 160 m PCA while 10 m loads
|
| 358 |
+
stop()
|
| 359 |
+
if (ticket !== pick.ticket) return
|
| 360 |
+
pick.shown = { ...view, sample: { ...sample, corners: view.corners } }
|
| 361 |
+
showThumbnail(view.src); showOnMap()
|
| 362 |
+
unbadge = loadingBadge('Loading high-res (10 m)', stats)
|
| 363 |
+
$('pick-stats').textContent = `${view.label} · loading high-res (10 m)…`
|
| 364 |
+
}
|
| 365 |
+
const t = await loading.finally(() => { stop(); unbadge() })
|
| 366 |
if (ticket !== pick.ticket) return
|
| 367 |
+
const label = { viirs: 'Radiance', aef: aefViews()[0].label }[info.kind] ?? 'Thumbnail'
|
| 368 |
+
const placed = t.corners ? { ...sample, corners: t.corners } : sample
|
| 369 |
+
pick.thumbnail = { ...t, sample: placed }
|
| 370 |
+
pick.shown = { src: t.src, values: t.values ?? null, size: t.size, sample: placed, label }
|
| 371 |
showThumbnail(t.src)
|
| 372 |
showOnMap()
|
| 373 |
meta(sample, t.productId, info)
|
| 374 |
+
if (info.kind === 'aef') aefMenu(label)
|
| 375 |
+
else if (info.kind) updateDownloads() // no band menu: the tile is one band
|
| 376 |
+
else buildViews(name, info, sample, ticket)
|
| 377 |
+
$('pick-stats').textContent = `${label}: ${kb(t.stats.bytes)} in ${reqs(t.stats.requests)} · ${secs(t.stats.read)}` +
|
| 378 |
(fresh ? '' : ' (loaded before)')
|
| 379 |
prefetchCell(pick.cell)
|
| 380 |
} catch (error) {
|
|
|
|
| 382 |
}
|
| 383 |
}
|
| 384 |
|
| 385 |
+
/** The badge over a preview while its high-res version loads: the share of its bytes so far. Returns the function
|
| 386 |
+
* that ends it. */
|
| 387 |
+
function loadingBadge(text, stats) {
|
| 388 |
+
const base = stats.bytes, badge = $('thumb-badge')
|
| 389 |
+
const update = () => {
|
| 390 |
+
const share = stats.expected > base ? Math.min(99, Math.round(100 * (stats.bytes - base) / (stats.expected - base))) : 0
|
| 391 |
+
$('thumb-badge-text').textContent = `${text} · ${share}%`
|
| 392 |
+
}
|
| 393 |
+
update(); badge.hidden = false
|
| 394 |
+
const timer = setInterval(update, 150)
|
| 395 |
+
return () => { clearInterval(timer); badge.hidden = true }
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
function showThumbnail(src, note = '') {
|
| 399 |
+
$('thumb-badge').hidden = true // a new image: no high-res load under way for it
|
| 400 |
$('thumb').hidden = !src
|
| 401 |
$('image-actions').hidden = !src
|
| 402 |
$('download-actions').hidden = !src
|
|
|
|
| 413 |
|
| 414 |
/** Corners of a sample's image, [lon, lat], from top left clockwise (as MapLibre's image source wants them). */
|
| 415 |
function corners(s) {
|
| 416 |
+
if (s.corners) return s.corners // VIIRS: from the GeoTIFF's own bounds
|
| 417 |
const zone = s.epsg % 100, south = s.epsg > 32700
|
| 418 |
const utm = proj4(`+proj=utm +zone=${zone}${south ? ' +south' : ''} +datum=WGS84 +units=m +no_defs`)
|
| 419 |
const [x, y] = utm.forward([s.centre_lon, s.centre_lat])
|
| 420 |
return [[x - HALF, y + HALF], [x + HALF, y + HALF], [x + HALF, y - HALF], [x - HALF, y - HALF]].map(p => utm.inverse(p))
|
| 421 |
}
|
| 422 |
|
| 423 |
+
/** The shown image over its footprint, opaque, while a cell is selected. */
|
| 424 |
function showOnMap() {
|
| 425 |
+
const t = pick.shown, on = t && pick.cell
|
| 426 |
if (map.getLayer('sample')) map.removeLayer('sample')
|
| 427 |
if (map.getSource('sample')) map.removeSource('sample')
|
| 428 |
if (!on) return
|
| 429 |
map.addSource('sample', { type: 'image', url: t.src, coordinates: corners(t.sample) })
|
| 430 |
map.addLayer({ id: 'sample', type: 'raster', source: 'sample',
|
| 431 |
+
paint: { 'raster-opacity': 1, 'raster-fade-duration': 0 } }, 'coast')
|
| 432 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
window.sampleCorners = () => (pick.shown ? corners(pick.shown.sample) : null) // for automated checks
|
| 434 |
|
| 435 |
+
// ---- Core-Weather at the acquisition --------------------------------------------------------------------------
|
| 436 |
+
|
| 437 |
+
const WEATHER_DATASETS = ['S2L2A', 'S2L1C', 'S1RTC'] // Core-Weather has rows for these three
|
| 438 |
+
|
| 439 |
+
/** The sample's hourly and daily weather in the panel. Datasets without an acquisition time of their own (DEM, VIIRS
|
| 440 |
+
* annual medians) show the weather at the cell's Sentinel-2 L2A acquisition, and say so. */
|
| 441 |
+
async function showWeather(sample, ticket) {
|
| 442 |
+
let at = sample, from = ''
|
| 443 |
+
if (!(sample.timestamp && WEATHER_DATASETS.includes(state.name))) {
|
| 444 |
+
const s2 = await samplesOf('S2L2A', pick.cell.row, pick.cell.col).catch(() => [])
|
| 445 |
+
if (ticket !== pick.ticket) return
|
| 446 |
+
at = s2[0]; from = at?.timestamp ? `At the cell's Sentinel-2 L2A acquisition, ${when(at.timestamp)}` : ''
|
| 447 |
+
}
|
| 448 |
+
$('weather').hidden = !at?.timestamp
|
| 449 |
+
if ($('weather').hidden) return
|
| 450 |
+
$('wx-from').textContent = from; $('wx-from').hidden = !from
|
| 451 |
+
$('wx-hourly').replaceChildren(); $('wx-daily').replaceChildren(); $('wx-stats').textContent = 'Loading Core-Weather…'
|
| 452 |
+
try {
|
| 453 |
+
const w = await loadWeather(pick.cell.name, at.timestamp)
|
| 454 |
+
if (ticket !== pick.ticket) return
|
| 455 |
+
$('wx-hourly').replaceChildren(...weatherRows(w.hourly, 'hourly'))
|
| 456 |
+
$('wx-daily').replaceChildren(...weatherRows(w.daily, 'daily'))
|
| 457 |
+
$('wx-stats').textContent = (w.hourly || w.daily ? '' : 'No Core-Weather row for this acquisition · ') +
|
| 458 |
+
`Core-Weather: ${kb(w.stats.bytes)} in ${reqs(w.stats.requests)} · ${secs(w.stats.read)}`
|
| 459 |
+
} catch (error) {
|
| 460 |
+
if (ticket === pick.ticket) $('wx-stats').textContent = `Could not load Core-Weather: ${error.message}`
|
| 461 |
+
}
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
// ---- views: the stored thumbnail, composites and single bands ------------------------------------------------
|
| 465 |
// the menu lists the image file's band columns; a chosen view is decoded once and kept (renders)
|
| 466 |
|
|
|
|
| 477 |
} catch { /* no menu: the thumbnail stays */ }
|
| 478 |
}
|
| 479 |
|
| 480 |
+
/** AlphaEarth's View menu: the PCA shown, then each band at 10 m. */
|
| 481 |
+
function aefMenu(label) {
|
| 482 |
+
pick.views = aefViews().slice(1)
|
| 483 |
+
$('view').replaceChildren(new Option(label, 'thumbnail'), ...pick.views.map(v => new Option(v.label, v.id)))
|
| 484 |
+
$('view').value = 'thumbnail'; $('view-row').hidden = false
|
| 485 |
+
updateDownloads()
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
$('view').addEventListener('change', () => showView($('view').value))
|
| 489 |
|
| 490 |
async function showView(id) {
|
| 491 |
const t = pick.thumbnail, ticket = ++pick.viewTicket
|
| 492 |
if (!t) return
|
| 493 |
+
if (id === 'thumbnail') {
|
| 494 |
+
pick.shown = { src: t.src, values: t.values ?? null, size: t.size, sample: t.sample, label: $('view').options[0].text }
|
| 495 |
+
refreshShown(); return
|
| 496 |
+
}
|
| 497 |
+
const view = pick.views.find(v => v.id === id), key = `${state.name}/${sampleKey(t.sample)}/${id}`
|
| 498 |
$('pick-stats').textContent = `Loading ${view.label}…`
|
| 499 |
const fresh = !renders.has(key), stats = newStats()
|
| 500 |
+
if (fresh) {
|
| 501 |
+
const job = state.info.kind === 'aef' ? renderAef(state.info, t.sample, id, stats) : render(state.info, t.sample, view, stats)
|
| 502 |
+
renders.set(key, job.catch(e => { renders.delete(key); throw e }))
|
| 503 |
+
}
|
| 504 |
const stop = fresh ? followLoad(`${view.label} · ${pick.cell.name}`, stats) : () => {}
|
| 505 |
try {
|
| 506 |
const r = await renders.get(key).finally(stop)
|
|
|
|
| 537 |
const shownBand = () => (pick.shown && pick.bands.includes(pick.shown.label) ? pick.shown.label : null)
|
| 538 |
|
| 539 |
function updateDownloads() {
|
| 540 |
+
const viirs = state.info?.kind === 'viirs'
|
| 541 |
+
$('dl-all').hidden = viirs
|
| 542 |
+
$('dl-all').textContent = 'All bands (.zip)'
|
| 543 |
+
if (state.info?.kind === 'aef') { // the shown image, or the shown band; all 64 bands at 10 m
|
| 544 |
+
const band = pick.shown?.window
|
| 545 |
+
$('dl-band').disabled = !pick.shown
|
| 546 |
+
$('dl-band').textContent = band ? 'Download band (.tif)' : 'Download image (.png)'
|
| 547 |
+
$('dl-band').title = band ? 'The band at 10 m: a GeoTIFF, int8, north up' : 'The image as shown'
|
| 548 |
+
$('dl-all').disabled = false; $('dl-all').textContent = 'All 64 bands (.tif)'
|
| 549 |
+
$('dl-all').title = 'Every band at 10 m: a GeoTIFF of about 73 MB; reads about 120 MB'
|
| 550 |
+
return
|
| 551 |
+
}
|
| 552 |
+
if (viirs) {
|
| 553 |
+
$('dl-band').disabled = false; $('dl-band').textContent = 'Download tile (.tif)'
|
| 554 |
+
$('dl-band').title = 'The tile as stored: a GeoTIFF, radiance in nW/cm²/sr'
|
| 555 |
+
return
|
| 556 |
+
}
|
| 557 |
const band = shownBand()
|
| 558 |
$('dl-band').disabled = !band
|
| 559 |
$('dl-band').textContent = band ? `Download ${band} (.tif)` : 'Download band (.tif)'
|
|
|
|
| 572 |
} finally { button.textContent = label; updateDownloads() }
|
| 573 |
}
|
| 574 |
|
| 575 |
+
const aefName = (sample, what) => `${pick.cell.name}_AEF_${sample.year}_${what}`
|
| 576 |
+
|
| 577 |
+
$('dl-band').addEventListener('click', async () => {
|
| 578 |
+
if (state.info.kind === 'aef') {
|
| 579 |
+
const s = pick.thumbnail.sample, w = pick.shown.window
|
| 580 |
+
if (w) downloadAefTiff(w, `${aefName(s, pick.shown.label.split(',')[0].replace('Band ', ''))}.tif`)
|
| 581 |
+
else await downloadImage(pick.shown.src, `${aefName(s, 'pca')}.png`)
|
| 582 |
+
$('pick-stats').textContent = 'Downloaded: from what is shown, nothing read'
|
| 583 |
+
return
|
| 584 |
+
}
|
| 585 |
+
if (state.info.kind === 'viirs') {
|
| 586 |
+
downloadViirs(pick.cell.name, pick.thumbnail.sample, pick.thumbnail.bytes)
|
| 587 |
+
$('pick-stats').textContent = `Downloaded: ${kb(pick.thumbnail.bytes.length)}, read before`
|
| 588 |
+
return
|
| 589 |
+
}
|
| 590 |
+
downloading($('dl-band'), () => downloadBand(state.info, state.name, pick.cell.name, pick.thumbnail.sample, shownBand()))
|
| 591 |
+
})
|
| 592 |
$('dl-all').addEventListener('click', () => downloading($('dl-all'), async () => {
|
| 593 |
const stats = newStats(), stop = followLoad(`All bands · ${pick.cell.name} · for download`, stats)
|
| 594 |
+
if (state.info.kind === 'aef') {
|
| 595 |
+
try {
|
| 596 |
+
const s = pick.thumbnail.sample, w = await aefWindow(state.info, s, 0, Array.from({ length: 64 }, (_, b) => b), stats)
|
| 597 |
+
downloadAefTiff(w, `${aefName(s, 'A00-A63')}.tif`)
|
| 598 |
+
stats.read = performance.now() - stats.t0
|
| 599 |
+
return stats
|
| 600 |
+
} finally { stop() }
|
| 601 |
+
}
|
| 602 |
try {
|
| 603 |
return await downloadAll(state.info, state.name, pick.cell.name, pick.thumbnail.sample, pick.thumbnail.src,
|
| 604 |
pick.thumbnail.productId, stats)
|
| 605 |
} finally { stop() }
|
| 606 |
}))
|
| 607 |
$('dl-python').addEventListener('click', async () => {
|
| 608 |
+
const code = state.info.kind === 'aef' ? aefSnippet(pick.cell.name, pick.thumbnail.sample, await aefCell(state.info, pick.thumbnail.sample))
|
| 609 |
+
: state.info.kind === 'viirs' ? viirsSnippet(state.info, pick.cell.name, pick.thumbnail.sample)
|
| 610 |
+
: pythonSnippet(state.info, state.name, pick.cell.name, pick.thumbnail.sample, pick.bands.length ? pick.bands : ['thumbnail'])
|
| 611 |
window.lastSnippet = code // for automated checks
|
| 612 |
try { await navigator.clipboard.writeText(code); $('pick-stats').textContent = 'Python copied to the clipboard' }
|
| 613 |
catch { window.prompt('Python that reads this sample:', code) }
|
|
|
|
| 674 |
if (sample.cloud_cover !== undefined && sample.cloud_cover !== null) rows.push(['Cloud cover', `${sample.cloud_cover.toFixed(1)}%`])
|
| 675 |
if (sample.orbit_state) rows.push(['Orbit', sample.orbit_state])
|
| 676 |
if (productId) rows.push(['Product', productId])
|
| 677 |
+
if (info.kind === 'aef') {
|
| 678 |
+
rows.push(['Year', `${sample.year} (annual embedding)${sample.year === sample.cellYear ? ', the Sentinel-2 year' : ''}`])
|
| 679 |
+
rows.push(['Source', `<a href="${info.link}" target="_blank" rel="noopener">tge-labs/aef</a>, CC-BY 4.0`])
|
| 680 |
+
} else if (info.kind === 'viirs') {
|
| 681 |
+
rows.push(['Year', `${sample.year} (annual median)`])
|
| 682 |
+
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')}`])
|
| 683 |
+
} else {
|
| 684 |
+
const url = imageUrl(info, sample.part)
|
| 685 |
+
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}`])
|
| 686 |
+
}
|
| 687 |
$('pick-meta').innerHTML = rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('')
|
| 688 |
}
|
build_index.py
CHANGED
|
@@ -9,8 +9,21 @@
|
|
| 9 |
row groups of 65,536 rows with statistics and a page index, no value dictionaries (as the climate files):
|
| 10 |
the app reads the two position columns for the coverage map and, for a click, the page holding the cell
|
| 11 |
-> footprints/datasets.json: per dataset its label, the URL base of its image files, its samples
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
"""
|
| 15 |
from __future__ import annotations
|
| 16 |
|
|
@@ -19,6 +32,7 @@ import re
|
|
| 19 |
import sys
|
| 20 |
from pathlib import Path
|
| 21 |
|
|
|
|
| 22 |
import numpy as np
|
| 23 |
import pyarrow as pa
|
| 24 |
import pyarrow.compute as pc
|
|
@@ -27,6 +41,9 @@ import pyarrow.parquet as pq
|
|
| 27 |
DATASETS = {"S2L2A": "Sentinel-2 L2A", "S2L1C": "Sentinel-2 L1C", "S1RTC": "Sentinel-1 RTC", "DEM": "COP-DEM"}
|
| 28 |
EXTRA = ["timestamp", "cloud_cover", "orbit_state"] # kept where a dataset has them
|
| 29 |
ROW_GROUP = 65_536
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
def index(metadata: Path, name: str) -> tuple[pa.Table, str]:
|
|
@@ -61,25 +78,120 @@ def epsg(crs: list[str]) -> np.ndarray:
|
|
| 61 |
return codes.astype(np.int16)
|
| 62 |
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
def write(table: pa.Table, path: Path) -> None:
|
| 65 |
-
keys = ["grid_row_u", "grid_col_r", "orbit_state", "epsg"]
|
| 66 |
pq.write_table(table, path, row_group_size=ROW_GROUP, compression="zstd", write_statistics=True,
|
| 67 |
write_page_index=True, use_dictionary=[c for c in keys if c in table.column_names],
|
| 68 |
data_page_size=4096)
|
| 69 |
|
| 70 |
|
| 71 |
-
def main(core: str, out: str) -> None:
|
| 72 |
core, out = Path(core), Path(out)
|
| 73 |
out.mkdir(parents=True, exist_ok=True)
|
| 74 |
-
listing = {}
|
| 75 |
-
for name
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
write(table, out / f"{name}.parquet")
|
| 78 |
-
listing[name] = {
|
| 79 |
-
"columns": table.column_names}
|
| 80 |
print(f"{name}: {table.num_rows:,} samples -> {(out / f'{name}.parquet').stat().st_size / 1e6:.1f} MB", flush=True)
|
|
|
|
|
|
|
| 81 |
(out / "datasets.json").write_text(json.dumps(listing, indent=1) + "\n")
|
| 82 |
|
| 83 |
|
| 84 |
if __name__ == "__main__":
|
| 85 |
-
main(*sys.argv[1:
|
|
|
|
| 9 |
row groups of 65,536 rows with statistics and a page index, no value dictionaries (as the climate files):
|
| 10 |
the app reads the two position columns for the coverage map and, for a click, the page holding the cell
|
| 11 |
-> footprints/datasets.json: per dataset its label, the URL base of its image files, its samples
|
| 12 |
+
Core-VIIRS-Nighttime-Light/INDEX.parquet on Hugging Face (one row per tile: id MT10_<cell>, year, shard, offset,
|
| 13 |
+
size, in no cell order)
|
| 14 |
+
-> footprints/VIIRS.parquet: grid_row_u, grid_col_r, year, shard, offset, size, sorted by cell; the app reads a
|
| 15 |
+
tile with one range request into its zip shard (viirs.js)
|
| 16 |
|
| 17 |
+
AlphaEarth (tge-labs/aef on source.coop): aef_index.parquet, one row per COG tile and year (81,920 m UTM squares,
|
| 18 |
+
the same 34,266 squares every year) + the Core-S2L2A cells
|
| 19 |
+
-> footprints/AEF.parquet: per cell grid_row_u, grid_col_r, centre_lat, centre_lon, year (its Sentinel-2 year,
|
| 20 |
+
within 2017-2025), tile_row, tile_rows: lists, one entry per tile its 10,680 m window touches (the tile
|
| 21 |
+
holding the centre first, then those under the window's corners in the same UTM zone; a zone's tiles share
|
| 22 |
+
one 81,920 m grid): where that tile's rows are in AEF-tiles.parquet; the tile holding the
|
| 23 |
+
cell centre, in the cell's own UTM zone where there is one
|
| 24 |
+
-> footprints/AEF-tiles.parquet: tile, year, epsg, west, north, path: one row per tile and year, by tile
|
| 25 |
+
|
| 26 |
+
python build_index.py /data/Major-TOM <out folder> [name ...] (Slurm, .venv311; names: rebuild only those)
|
| 27 |
"""
|
| 28 |
from __future__ import annotations
|
| 29 |
|
|
|
|
| 32 |
import sys
|
| 33 |
from pathlib import Path
|
| 34 |
|
| 35 |
+
import fsspec
|
| 36 |
import numpy as np
|
| 37 |
import pyarrow as pa
|
| 38 |
import pyarrow.compute as pc
|
|
|
|
| 41 |
DATASETS = {"S2L2A": "Sentinel-2 L2A", "S2L1C": "Sentinel-2 L1C", "S1RTC": "Sentinel-1 RTC", "DEM": "COP-DEM"}
|
| 42 |
EXTRA = ["timestamp", "cloud_cover", "orbit_state"] # kept where a dataset has them
|
| 43 |
ROW_GROUP = 65_536
|
| 44 |
+
VIIRS = "https://huggingface.co/datasets/Major-TOM/Core-VIIRS-Nighttime-Light/resolve/main/"
|
| 45 |
+
AEF = "https://data.source.coop/tge-labs/aef/v1/annual/"
|
| 46 |
+
AEF_TILE = 81_920 # metres: every AlphaEarth COG
|
| 47 |
|
| 48 |
|
| 49 |
def index(metadata: Path, name: str) -> tuple[pa.Table, str]:
|
|
|
|
| 78 |
return codes.astype(np.int16)
|
| 79 |
|
| 80 |
|
| 81 |
+
def viirs() -> pa.Table:
|
| 82 |
+
"""The VIIRS footprint table, from the dataset's INDEX.parquet: the cell from the tile id, the tile's place."""
|
| 83 |
+
with fsspec.open(f"{VIIRS}INDEX.parquet", "rb", block_size=2**24) as f:
|
| 84 |
+
table = pq.read_table(f, columns=["id", "year", "shard", "offset", "size"])
|
| 85 |
+
parts = pc.extract_regex(table.column("id"), r"^MT10_(?P<r>\d+)(?P<u>[UD])_(?P<c>\d+)(?P<l>[LR])$")
|
| 86 |
+
if parts.null_count:
|
| 87 |
+
raise ValueError("a VIIRS id outside MT10_<row><U|D>_<col><L|R>")
|
| 88 |
+
sign = lambda letter, plus: pc.if_else(pc.equal(pc.struct_field(parts, letter), plus), 1, -1)
|
| 89 |
+
row = pc.multiply(pc.cast(pc.struct_field(parts, "r"), pa.int16()), pc.cast(sign("u", "U"), pa.int16()))
|
| 90 |
+
col = pc.multiply(pc.cast(pc.struct_field(parts, "c"), pa.int16()), pc.cast(sign("l", "R"), pa.int16()))
|
| 91 |
+
out = pa.table({"grid_row_u": row, "grid_col_r": col, "year": pc.cast(table.column("year"), pa.int16()),
|
| 92 |
+
"shard": table.column("shard"), "offset": pc.cast(table.column("offset"), pa.int64()),
|
| 93 |
+
"size": pc.cast(table.column("size"), pa.int32())})
|
| 94 |
+
return out.sort_by([("grid_row_u", "ascending"), ("grid_col_r", "ascending"), ("year", "ascending")])
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def aef(core: Path) -> tuple[pa.Table, pa.Table]:
|
| 98 |
+
"""AlphaEarth: the cell table (Core-S2L2A cells -> the tile holding each centre) and the tile table."""
|
| 99 |
+
import pandas as pd
|
| 100 |
+
from pyproj import Transformer
|
| 101 |
+
|
| 102 |
+
with fsspec.open(f"{AEF}aef_index.parquet", "rb", block_size=2**24) as f:
|
| 103 |
+
idx = pq.read_table(f, columns=["path", "year", "crs", "utm_west", "utm_south"]).to_pandas()
|
| 104 |
+
idx["epsg"] = idx.crs.str.split(":").str[-1].astype(int)
|
| 105 |
+
idx["path"] = idx.path.str.replace("s3://us-west-2.opendata.source.coop/tge-labs/aef/v1/annual/", "", regex=False)
|
| 106 |
+
squares = idx.drop_duplicates(["epsg", "utm_west", "utm_south"])[["epsg", "utm_west", "utm_south"]]
|
| 107 |
+
squares = squares.sort_values(["epsg", "utm_south", "utm_west"]).reset_index(drop=True)
|
| 108 |
+
squares["tile"] = np.arange(len(squares), dtype=np.int32)
|
| 109 |
+
tiles = idx.merge(squares, on=["epsg", "utm_west", "utm_south"]).sort_values(["tile", "year"]).reset_index(drop=True)
|
| 110 |
+
|
| 111 |
+
cells = pq.read_table(core / "Core-S2L2A" / "metadata.parquet",
|
| 112 |
+
columns=["grid_row_u", "grid_col_r", "centre_lat", "centre_lon", "crs", "timestamp"]).to_pandas()
|
| 113 |
+
cells["own"] = epsg(cells.crs.tolist()).astype(int)
|
| 114 |
+
cells["tile"] = -1
|
| 115 |
+
for own_zone_first in (True, False): # a cell near a zone edge may lie in a neighbour's square
|
| 116 |
+
for code, group in squares.groupby("epsg"):
|
| 117 |
+
pick = (cells.tile < 0) & ((cells.own == code) if own_zone_first else (abs(cells.own - code) == 1))
|
| 118 |
+
if not pick.any():
|
| 119 |
+
continue
|
| 120 |
+
x, y = Transformer.from_crs(4326, int(code), always_xy=True).transform(
|
| 121 |
+
cells.centre_lon[pick].to_numpy(), cells.centre_lat[pick].to_numpy())
|
| 122 |
+
west, south = group.utm_west.to_numpy(), group.utm_south.to_numpy()
|
| 123 |
+
inside = ((x[:, None] >= west) & (x[:, None] < west + AEF_TILE) &
|
| 124 |
+
(y[:, None] >= south) & (y[:, None] < south + AEF_TILE))
|
| 125 |
+
hit = inside.any(1)
|
| 126 |
+
cells.loc[cells.index[pick][hit], "tile"] = group.tile.to_numpy()[inside[hit].argmax(1)]
|
| 127 |
+
cells = cells[cells.tile >= 0].reset_index(drop=True)
|
| 128 |
+
first = tiles.reset_index().groupby("tile")["index"].agg(["min", "count"])
|
| 129 |
+
lookup = {(e, w, s): k for e, w, s, k in squares[["epsg", "utm_west", "utm_south", "tile"]].itertuples(index=False)}
|
| 130 |
+
parts = [[k] for k in cells.tile] # tiles of each cell's window, its own first
|
| 131 |
+
own = squares.set_index("tile").loc[cells.tile]
|
| 132 |
+
for code in np.unique(own.epsg):
|
| 133 |
+
rows = np.flatnonzero(own.epsg.to_numpy() == code)
|
| 134 |
+
x, y = Transformer.from_crs(4326, int(code), always_xy=True).transform(
|
| 135 |
+
cells.centre_lon.to_numpy()[rows], cells.centre_lat.to_numpy()[rows])
|
| 136 |
+
w0, s0 = own.utm_west.to_numpy()[rows], own.utm_south.to_numpy()[rows]
|
| 137 |
+
for dx in (-5340, 5340):
|
| 138 |
+
for dy in (-5340, 5340):
|
| 139 |
+
west = w0 + np.floor((x + dx - w0) / AEF_TILE) * AEF_TILE
|
| 140 |
+
south = s0 + np.floor((y + dy - s0) / AEF_TILE) * AEF_TILE
|
| 141 |
+
for r, wv, sv in zip(rows[(west != w0) | (south != s0)], west[(west != w0) | (south != s0)],
|
| 142 |
+
south[(west != w0) | (south != s0)]):
|
| 143 |
+
k = lookup.get((int(code), float(wv), float(sv)))
|
| 144 |
+
if k is not None and k not in parts[r]:
|
| 145 |
+
parts[r].append(k)
|
| 146 |
+
year = cells.timestamp.str[:4].astype(int).clip(2017, 2025)
|
| 147 |
+
cell_table = pa.table({"grid_row_u": pa.array(cells.grid_row_u.to_numpy(np.int16)),
|
| 148 |
+
"grid_col_r": pa.array(cells.grid_col_r.to_numpy(np.int16)),
|
| 149 |
+
"centre_lat": pa.array(cells.centre_lat.to_numpy(np.float32)),
|
| 150 |
+
"centre_lon": pa.array(cells.centre_lon.to_numpy(np.float32)),
|
| 151 |
+
"year": pa.array(year.to_numpy(np.int16)),
|
| 152 |
+
"tile_row": pa.array([[int(first.at[k, "min"]) for k in ks] for ks in parts], pa.list_(pa.int32())),
|
| 153 |
+
"tile_rows": pa.array([[int(first.at[k, "count"]) for k in ks] for ks in parts], pa.list_(pa.int16()))})
|
| 154 |
+
counts = np.bincount([len(ks) for ks in parts])
|
| 155 |
+
print("AEF tiles per cell window:", {n: int(c) for n, c in enumerate(counts) if c}, flush=True)
|
| 156 |
+
tile_table = pa.table({"tile": pa.array(tiles.tile.to_numpy(np.int32)), "year": pa.array(tiles.year.to_numpy(np.int16)),
|
| 157 |
+
"epsg": pa.array(tiles.epsg.to_numpy(np.int32)),
|
| 158 |
+
"west": pa.array(tiles.utm_west.to_numpy(np.float64)),
|
| 159 |
+
"north": pa.array((tiles.utm_south + AEF_TILE).to_numpy(np.float64)),
|
| 160 |
+
"path": pa.array(tiles.path.tolist())})
|
| 161 |
+
return cell_table.sort_by([("grid_row_u", "ascending"), ("grid_col_r", "ascending")]), tile_table
|
| 162 |
+
|
| 163 |
+
|
| 164 |
def write(table: pa.Table, path: Path) -> None:
|
| 165 |
+
keys = ["grid_row_u", "grid_col_r", "orbit_state", "epsg", "shard"]
|
| 166 |
pq.write_table(table, path, row_group_size=ROW_GROUP, compression="zstd", write_statistics=True,
|
| 167 |
write_page_index=True, use_dictionary=[c for c in keys if c in table.column_names],
|
| 168 |
data_page_size=4096)
|
| 169 |
|
| 170 |
|
| 171 |
+
def main(core: str, out: str, *only: str) -> None:
|
| 172 |
core, out = Path(core), Path(out)
|
| 173 |
out.mkdir(parents=True, exist_ok=True)
|
| 174 |
+
listing = json.loads((out / "datasets.json").read_text()) if only else {}
|
| 175 |
+
for name in only or [*DATASETS, "VIIRS", "AEF"]:
|
| 176 |
+
if name == "AEF":
|
| 177 |
+
table, tile_table = aef(core)
|
| 178 |
+
write(tile_table, out / "AEF-tiles.parquet")
|
| 179 |
+
entry = {"label": "AlphaEarth Embeddings", "kind": "aef", "files": AEF, "tiles": "AEF-tiles.parquet",
|
| 180 |
+
"years": [2017, 2025], "link": "https://source.coop/tge-labs/aef"}
|
| 181 |
+
elif name == "VIIRS":
|
| 182 |
+
table = viirs()
|
| 183 |
+
entry = {"label": "VIIRS Nighttime Light", "kind": "viirs", "files": VIIRS,
|
| 184 |
+
"link": "https://huggingface.co/datasets/Major-TOM/Core-VIIRS-Nighttime-Light"}
|
| 185 |
+
else:
|
| 186 |
+
table, base = index(core / f"Core-{name}" / "metadata.parquet", name)
|
| 187 |
+
entry = {"label": DATASETS[name], "images": base}
|
| 188 |
write(table, out / f"{name}.parquet")
|
| 189 |
+
listing[name] = {**entry, "samples": table.num_rows, "columns": table.column_names}
|
|
|
|
| 190 |
print(f"{name}: {table.num_rows:,} samples -> {(out / f'{name}.parquet').stat().st_size / 1e6:.1f} MB", flush=True)
|
| 191 |
+
order = [*DATASETS, "VIIRS", "AEF"]
|
| 192 |
+
listing = {k: listing[k] for k in sorted(listing, key=order.index)}
|
| 193 |
(out / "datasets.json").write_text(json.dumps(listing, indent=1) + "\n")
|
| 194 |
|
| 195 |
|
| 196 |
if __name__ == "__main__":
|
| 197 |
+
main(*sys.argv[1:])
|
download.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
| 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'
|
|
@@ -54,3 +56,94 @@ with fsspec.open(url, "rb") as f: # reads only the parts of
|
|
| 54 |
# from rasterio.io import MemoryFile; MemoryFile(row["${bands[0]}"][0].as_py()).open().read(1)
|
| 55 |
`
|
| 56 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
// VIIRS -> the tile's GeoTIFF as stored in its zip shard (already read); Python reads the same byte range
|
| 7 |
+
// AlphaEarth -> the shown image (.png); the shown band or all 64 at 10 m as a GeoTIFF written here; Python (rasterio)
|
| 8 |
|
| 9 |
import { zipSync } from 'https://cdn.jsdelivr.net/npm/fflate@0.8.2/+esm'
|
| 10 |
import { bandNames, readBands } from './bands.js'
|
|
|
|
| 56 |
# from rasterio.io import MemoryFile; MemoryFile(row["${bands[0]}"][0].as_py()).open().read(1)
|
| 57 |
`
|
| 58 |
}
|
| 59 |
+
|
| 60 |
+
/** A VIIRS tile's GeoTIFF, as stored in its zip shard (the bytes the panel already read). */
|
| 61 |
+
export function downloadViirs(cell, sample, bytes) {
|
| 62 |
+
save(new Blob([bytes], { type: 'image/tiff' }), `${cell}_VIIRS_${sample.year}.tif`)
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/** Python that reads the same VIIRS tile: its byte range of the zip shard, opened as a GeoTIFF. */
|
| 66 |
+
export function viirsSnippet(info, cell, sample) {
|
| 67 |
+
return `import fsspec
|
| 68 |
+
from rasterio.io import MemoryFile
|
| 69 |
+
|
| 70 |
+
url = "${info.files}${sample.shard}"
|
| 71 |
+
with fsspec.open(url, "rb") as f: # one range request: the tile is stored uncompressed in the zip
|
| 72 |
+
f.seek(${sample.offset}); tile = f.read(${sample.size})
|
| 73 |
+
radiance = MemoryFile(tile).open().read(1) # ${cell}, ${sample.year}: nW/cm²/sr, 10 m, float32
|
| 74 |
+
`
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// ---- AlphaEarth: GeoTIFFs written here (int8, uncompressed, one strip per band), the shown image, Python ---------
|
| 78 |
+
|
| 79 |
+
const TIFF_BYTES = { 2: 1, 3: 2, 4: 4, 12: 8 } // ASCII, SHORT, LONG, DOUBLE
|
| 80 |
+
|
| 81 |
+
/** int8 bands (north up, NODATA -128) as a GeoTIFF in a UTM zone: west and north edges in metres, square pixels. */
|
| 82 |
+
export function int8GeoTiff(bands, size, { epsg, west, north, res }) {
|
| 83 |
+
const n = bands.length, plane = size * size
|
| 84 |
+
const entries = [[256, 4, [size]], [257, 4, [size]], [258, 3, Array(n).fill(8)], [259, 3, [1]], [262, 3, [1]],
|
| 85 |
+
[273, 4, Array(n).fill(0)], [277, 3, [n]], [278, 4, [size]], [279, 4, Array(n).fill(plane)], [284, 3, [2]],
|
| 86 |
+
...(n > 1 ? [[338, 3, Array(n - 1).fill(0)]] : []), [339, 3, Array(n).fill(2)],
|
| 87 |
+
[33550, 12, [res, res, 0]], [33922, 12, [0, 0, 0, west, north, 0]],
|
| 88 |
+
[34735, 3, [1, 1, 0, 3, 1024, 0, 1, 1, 1025, 0, 1, 1, 3072, 0, 1, epsg]], // projected, pixel is area, EPSG
|
| 89 |
+
[42113, 2, [...'-128'].map(c => c.charCodeAt(0)).concat(0)]] // GDAL_NODATA
|
| 90 |
+
let at = 8 + 2 + 12 * entries.length + 4
|
| 91 |
+
const places = entries.map(([, type, values]) => {
|
| 92 |
+
const bytes = values.length * TIFF_BYTES[type]
|
| 93 |
+
if (bytes <= 4) return null
|
| 94 |
+
const here = at; at += bytes + (bytes % 2); return here
|
| 95 |
+
})
|
| 96 |
+
entries[5][2] = bands.map((_, b) => at + b * plane) // StripOffsets: after the values
|
| 97 |
+
const buffer = new ArrayBuffer(at + n * plane), view = new DataView(buffer), bytes = new Uint8Array(buffer)
|
| 98 |
+
const put = (offset, type, v) => {
|
| 99 |
+
if (type === 2) view.setUint8(offset, v)
|
| 100 |
+
else if (type === 3) view.setUint16(offset, v, true)
|
| 101 |
+
else if (type === 4) view.setUint32(offset, v, true)
|
| 102 |
+
else view.setFloat64(offset, v, true)
|
| 103 |
+
}
|
| 104 |
+
view.setUint16(0, 0x4949, true); view.setUint16(2, 42, true); view.setUint32(4, 8, true)
|
| 105 |
+
view.setUint16(8, entries.length, true)
|
| 106 |
+
entries.forEach(([tag, type, values], k) => {
|
| 107 |
+
const e = 10 + 12 * k
|
| 108 |
+
view.setUint16(e, tag, true); view.setUint16(e + 2, type, true); view.setUint32(e + 4, values.length, true)
|
| 109 |
+
const base = places[k] ?? e + 8
|
| 110 |
+
if (places[k] !== null) view.setUint32(e + 8, base, true)
|
| 111 |
+
values.forEach((v, i) => put(base + i * TIFF_BYTES[type], type, v))
|
| 112 |
+
})
|
| 113 |
+
bands.forEach((band, b) => bytes.set(new Uint8Array(band.buffer, band.byteOffset, plane), at + b * plane))
|
| 114 |
+
return new Blob([buffer], { type: 'image/tiff' })
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
/** The shown image (a PNG object URL), saved as drawn. */
|
| 118 |
+
export async function downloadImage(src, filename) {
|
| 119 |
+
save(await (await fetch(src)).blob(), filename)
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/** int8 bands of the AlphaEarth window as a GeoTIFF. */
|
| 123 |
+
export function downloadAefTiff(window, filename) {
|
| 124 |
+
save(int8GeoTiff(window.bands, window.size, { epsg: window.cell.epsg, west: window.west, north: window.north, res: window.res }),
|
| 125 |
+
filename)
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/** Python that reads the same AlphaEarth window: each tile's part (a windowed read; the files are stored south-up),
|
| 129 |
+
* joined, north up, on the 10 m grid the app uses. */
|
| 130 |
+
export function aefSnippet(cellName, sample, cell) {
|
| 131 |
+
const west = Math.round((cell.cx - 5340) / 10) * 10, north = Math.round((cell.cy - 5340) / 10) * 10 + 10680
|
| 132 |
+
return `import numpy as np
|
| 133 |
+
import rasterio
|
| 134 |
+
from rasterio.windows import Window
|
| 135 |
+
|
| 136 |
+
urls = ${JSON.stringify(cell.urls, null, 4).replace(/\n\]$/, ',\n]')}
|
| 137 |
+
west, north = ${west}, ${north} # ${cellName}, EPSG:${cell.epsg}: 1,068 x 1,068 pixels of 10 m
|
| 138 |
+
embedding = np.full((64, 1068, 1068), -128, np.int8) # AlphaEarth ${sample.year} (tge-labs/aef, CC-BY 4.0)
|
| 139 |
+
for url in urls: # every tile under the cell
|
| 140 |
+
with rasterio.open(url) as src:
|
| 141 |
+
t = src.transform # t.e > 0: stored south-up
|
| 142 |
+
edge = north - 10680 if t.e > 0 else north
|
| 143 |
+
win = Window(round((west - t.c) / t.a), round((edge - t.f) / t.e), 1068, 1068)
|
| 144 |
+
part = src.read(window=win, boundless=True, fill_value=-128)
|
| 145 |
+
if t.e > 0:
|
| 146 |
+
part = part[:, ::-1] # north up
|
| 147 |
+
embedding = np.where(embedding == -128, part, embedding)
|
| 148 |
+
`
|
| 149 |
+
}
|
index.html
CHANGED
|
@@ -83,6 +83,12 @@
|
|
| 83 |
#thumb { width: 100%; height: 100%; object-fit: contain; display: block; }
|
| 84 |
#thumb[hidden] { display: none; }
|
| 85 |
#thumb { cursor: zoom-in; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
.image-actions { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; margin: -2px 0 10px; font-size: 13px; }
|
| 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; }
|
|
@@ -111,6 +117,26 @@
|
|
| 111 |
#choices button { font: 12px var(--mono); padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px;
|
| 112 |
background: var(--surface); color: var(--ink-2); cursor: pointer; }
|
| 113 |
#choices button[aria-pressed=true] { border-color: var(--mt-blue); color: var(--mt-blue); background: var(--tint); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
.tiny { font: 11px/1.4 var(--mono); color: var(--ink-3); min-height: 1em; margin-top: 8px; }
|
| 115 |
dl { display: grid; grid-template-columns: auto 1fr; gap: 3px 14px; margin: 0; font-size: 14px; }
|
| 116 |
dt { color: var(--ink-3); }
|
|
@@ -180,11 +206,9 @@
|
|
| 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">
|
| 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>
|
|
@@ -193,6 +217,14 @@
|
|
| 193 |
</div>
|
| 194 |
<dl id="pick-meta"></dl>
|
| 195 |
<div class="tiny" id="pick-stats"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
</section>
|
| 197 |
|
| 198 |
<section>
|
|
|
|
| 83 |
#thumb { width: 100%; height: 100%; object-fit: contain; display: block; }
|
| 84 |
#thumb[hidden] { display: none; }
|
| 85 |
#thumb { cursor: zoom-in; }
|
| 86 |
+
#thumb-badge { position: absolute; left: 8px; bottom: 8px; display: flex; align-items: center; gap: 7px; padding: 4px 10px 4px 8px;
|
| 87 |
+
border-radius: 999px; background: rgba(17, 24, 39, .78); color: #fff; font: 12px var(--sans); pointer-events: none; }
|
| 88 |
+
#thumb-badge[hidden] { display: none; }
|
| 89 |
+
#thumb-badge .spin { width: 11px; height: 11px; border-radius: 50%; border: 2px solid rgba(255, 255, 255, .35);
|
| 90 |
+
border-top-color: #fff; animation: spin .8s linear infinite; }
|
| 91 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 92 |
.image-actions { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; margin: -2px 0 10px; font-size: 13px; }
|
| 93 |
.image-actions button { font: inherit; padding: 4px 10px; border: 1px solid var(--line); border-radius: 8px;
|
| 94 |
background: var(--surface); color: var(--ink); cursor: pointer; }
|
|
|
|
| 117 |
#choices button { font: 12px var(--mono); padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px;
|
| 118 |
background: var(--surface); color: var(--ink-2); cursor: pointer; }
|
| 119 |
#choices button[aria-pressed=true] { border-color: var(--mt-blue); color: var(--mt-blue); background: var(--tint); }
|
| 120 |
+
#weather { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); }
|
| 121 |
+
#weather h3 { margin: 0 0 2px; font-size: 15px; font-weight: 600; }
|
| 122 |
+
.wx-head { font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--ink-3); margin: 14px 0 4px; }
|
| 123 |
+
.wx-row { margin: 8px 0 0; }
|
| 124 |
+
.wx-top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
|
| 125 |
+
.wx-label { font-size: 14px; color: var(--ink); }
|
| 126 |
+
.wx-label small { font-size: 12px; color: var(--ink-3); margin-left: 2px; }
|
| 127 |
+
.wx-value { font: 500 14px var(--mono); white-space: nowrap; }
|
| 128 |
+
.wx-value small { font-size: 11px; color: var(--ink-3); font-weight: 400; }
|
| 129 |
+
.wx-chart { display: block; width: 100%; height: auto; margin-top: 2px; overflow: visible; }
|
| 130 |
+
.wx-line { fill: none; stroke: var(--mt-blue); stroke-width: 1.6; stroke-linejoin: round; }
|
| 131 |
+
.wx-area { fill: var(--mt-blue); opacity: .08; }
|
| 132 |
+
.wx-bars rect { fill: var(--mt-blue); }
|
| 133 |
+
.wx-dots circle { fill: var(--mt-blue); }
|
| 134 |
+
.wx-base { stroke: var(--line); }
|
| 135 |
+
.wx-zero { stroke: var(--mt-orange); stroke-width: 1; stroke-dasharray: 2 2; }
|
| 136 |
+
.wx-now { fill: var(--mt-orange); stroke: var(--surface); stroke-width: 1.5; }
|
| 137 |
+
.wx-tick { font: 10px var(--mono); fill: var(--ink-3); text-anchor: end; }
|
| 138 |
+
.wx-none { font: 11px var(--sans); fill: var(--ink-3); text-anchor: middle; }
|
| 139 |
+
.wx-axis { display: flex; justify-content: space-between; font: 10px var(--mono); color: var(--ink-3); margin: 4px 40px 0 0; padding-top: 3px; border-top: 1px solid var(--line); }
|
| 140 |
.tiny { font: 11px/1.4 var(--mono); color: var(--ink-3); min-height: 1em; margin-top: 8px; }
|
| 141 |
dl { display: grid; grid-template-columns: auto 1fr; gap: 3px 14px; margin: 0; font-size: 14px; }
|
| 142 |
dt { color: var(--ink-3); }
|
|
|
|
| 206 |
<div class="pick-name"><b id="pick-cell"></b><span id="pick-pos"></span></div>
|
| 207 |
<div id="choices"></div>
|
| 208 |
<label class="view-row" id="view-row" hidden>View <select id="view" aria-label="Band or composite"></select></label>
|
| 209 |
+
<div id="thumb-wrap"><img id="thumb" alt="Thumbnail of the selected sample" hidden><span id="thumb-note"></span><div id="thumb-badge" hidden><span class="spin"></span><span id="thumb-badge-text"></span></div></div>
|
| 210 |
<div class="image-actions" id="image-actions" hidden>
|
| 211 |
+
<button id="open-image" title="The image in the full window: zoom, pan, value under the cursor">Enlarge</button>
|
|
|
|
|
|
|
| 212 |
</div>
|
| 213 |
<div class="image-actions" id="download-actions" hidden>
|
| 214 |
<button id="dl-band" title="The shown band as stored: a GeoTIFF">Download band (.tif)</button>
|
|
|
|
| 217 |
</div>
|
| 218 |
<dl id="pick-meta"></dl>
|
| 219 |
<div class="tiny" id="pick-stats"></div>
|
| 220 |
+
<div id="weather" hidden>
|
| 221 |
+
<h3>Weather at acquisition</h3>
|
| 222 |
+
<p class="note" id="wx-from" hidden></p>
|
| 223 |
+
<div class="wx-head">Hourly · 24 hours either side</div><div id="wx-hourly"></div>
|
| 224 |
+
<div class="wx-head">Daily · 30 days either side</div><div id="wx-daily"></div>
|
| 225 |
+
<div class="tiny" id="wx-stats"></div>
|
| 226 |
+
<p class="note">Means over the 10 km cell, from Core-Weather on source.coop. Orange: the acquisition.</p>
|
| 227 |
+
</div>
|
| 228 |
</section>
|
| 229 |
|
| 230 |
<section>
|
viirs.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// VIIRS Nighttime Light tiles (Major-TOM/Core-VIIRS-Nighttime-Light), read from their zip shards and coloured here.
|
| 2 |
+
//
|
| 3 |
+
// footprint row (shard, offset, size) -> one range request: the tile's GeoTIFF, stored uncompressed in the zip
|
| 4 |
+
// -> geotiff.js (zstd tiles: decoder in zstd.js) -> radiance, nW/cm²/sr
|
| 5 |
+
// -> log scale 0.1-100 in inferno, the Core Viewer's fixed scale, so tiles compare -> PNG
|
| 6 |
+
// the GeoTIFF bytes are kept: the download is the tile as stored
|
| 7 |
+
// corners from the GeoTIFF's own bounds and UTM zone: VIIRS tiles are 1,056 px at 10 m on the v2 grid, not the
|
| 8 |
+
// Core images' 10,680 m squares
|
| 9 |
+
|
| 10 |
+
import { fromArrayBuffer } from 'https://cdn.jsdelivr.net/npm/geotiff@2.1.3/+esm'
|
| 11 |
+
import proj4 from 'https://cdn.jsdelivr.net/npm/proj4@2.12.1/+esm'
|
| 12 |
+
import './zstd.js'
|
| 13 |
+
|
| 14 |
+
// matplotlib's inferno at nine stops
|
| 15 |
+
const INFERNO = [[0, 0, 4], [31, 12, 72], [85, 15, 109], [136, 34, 106], [186, 54, 85], [227, 89, 51], [249, 140, 10],
|
| 16 |
+
[249, 201, 50], [252, 255, 164]]
|
| 17 |
+
const LO = Math.log10(0.1), HI = Math.log10(100)
|
| 18 |
+
|
| 19 |
+
function inferno(t) {
|
| 20 |
+
const f = Math.min(Math.max(t, 0), 1) * (INFERNO.length - 1), k = Math.min(Math.floor(f), INFERNO.length - 2), u = f - k
|
| 21 |
+
return INFERNO[k].map((c, i) => c + u * (INFERNO[k + 1][i] - c))
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/** The sample's tile: {src (PNG object URL), size, values (radiance, for the cursor), corners, bytes (the GeoTIFF), stats}. */
|
| 25 |
+
export async function loadViirs(info, sample, stats) {
|
| 26 |
+
const r = await fetch(`${info.files}${sample.shard}`, { headers: { Range: `bytes=${sample.offset}-${sample.offset + sample.size - 1}` } })
|
| 27 |
+
if (r.status !== 206) throw new Error(`${sample.shard}: HTTP ${r.status}, not a byte range`)
|
| 28 |
+
const buffer = await r.arrayBuffer()
|
| 29 |
+
stats.bytes += buffer.byteLength; stats.requests += 1
|
| 30 |
+
const image = await (await fromArrayBuffer(buffer)).getImage(), [values] = await image.readRasters()
|
| 31 |
+
const size = image.getWidth(), canvas = new OffscreenCanvas(size, size), ctx = canvas.getContext('2d')
|
| 32 |
+
const img = ctx.createImageData(size, size)
|
| 33 |
+
for (let i = 0; i < values.length; i++) {
|
| 34 |
+
const v = values[i], o = i * 4
|
| 35 |
+
if (!Number.isFinite(v)) continue // transparent
|
| 36 |
+
const [red, green, blue] = inferno((Math.log10(Math.max(v, 0.1)) - LO) / (HI - LO))
|
| 37 |
+
img.data[o] = red; img.data[o + 1] = green; img.data[o + 2] = blue; img.data[o + 3] = 255
|
| 38 |
+
}
|
| 39 |
+
ctx.putImageData(img, 0, 0)
|
| 40 |
+
const src = URL.createObjectURL(await canvas.convertToBlob({ type: 'image/png' }))
|
| 41 |
+
const [west, south, east, north] = image.getBoundingBox(), epsg = image.geoKeys.ProjectedCSTypeGeoKey
|
| 42 |
+
const utm = proj4(`+proj=utm +zone=${epsg % 100}${epsg > 32700 ? ' +south' : ''} +datum=WGS84 +units=m +no_defs`)
|
| 43 |
+
const corners = [[west, north], [east, north], [east, south], [west, south]].map(p => utm.inverse(p))
|
| 44 |
+
stats.read = performance.now() - stats.t0
|
| 45 |
+
return { src, size, values: { width: size, values, unit: 'nW/cm²/sr' }, corners, bytes: new Uint8Array(buffer), stats }
|
| 46 |
+
}
|
weather.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Core-Weather for one sample, from source.coop: the hourly (±24 h) and daily (±30 d) rows of its acquisition.
|
| 2 |
+
//
|
| 3 |
+
// sample (cell name, timestamp "20221211T114501") -> <hourly|daily>/year=YYYY/YYYY-MM-DD.parquet (one row group,
|
| 4 |
+
// rows sorted by grid_cell) -> footer, grid_cell and acquired (whole column, ~20 KB) -> the sample's row range
|
| 5 |
+
// -> the chart columns for that range only -> one small chart per variable, step 0 marked
|
| 6 |
+
// ERA5 for temperature, precipitation and cloud: it covers every cell (ERA5-Land is land only, and south of 57.1°S
|
| 7 |
+
// is not filled yet).
|
| 8 |
+
|
| 9 |
+
import { compressors, newStats, openFile, parquetRead } from './parquet.js'
|
| 10 |
+
|
| 11 |
+
export const WEATHER = 'https://data.source.coop/major-tom/core-weather'
|
| 12 |
+
|
| 13 |
+
// [column, label, source, unit, value as shown, mark]: line (states), bars from zero (amounts), dots (one overpass a day)
|
| 14 |
+
const HOURLY = [
|
| 15 |
+
['era5_t2m', 'Temperature', 'ERA5, 2 m', '°C', v => v - 273.15, 'line'],
|
| 16 |
+
['era5_tp', 'Precipitation', 'ERA5', 'mm', v => v * 1000, 'bars'],
|
| 17 |
+
['era5_tcc', 'Cloud cover', 'ERA5', '%', v => v * 100, 'line'],
|
| 18 |
+
]
|
| 19 |
+
const DAILY = [
|
| 20 |
+
['era5_t2m_mean', 'Temperature', 'ERA5, 2 m, daily mean', '°C', v => v - 273.15, 'line'],
|
| 21 |
+
['era5_tp_sum', 'Precipitation', 'ERA5', 'mm', v => v * 1000, 'bars'],
|
| 22 |
+
['cams_pm2p5_mean', 'PM2.5', 'CAMS', 'µg/m³', v => v * 1e9, 'line'],
|
| 23 |
+
['s5p_no2', 'NO₂ column', 'Sentinel-5P', 'µmol/m²', v => v * 1e6, 'dots'],
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
const ms = v => (v instanceof Date ? v.getTime() : typeof v === 'bigint' ? Number(v / 1000000n) : Number(v))
|
| 27 |
+
|
| 28 |
+
/** The rows of one table for (cell, acquired ms): {step: [...], <column>: [...]}, or null if the cell is absent. */
|
| 29 |
+
async function readTable(table, day, cell, acquired, columns, step, stats) {
|
| 30 |
+
const url = `${WEATHER}/${table}/year=${day.slice(0, 4)}/${day}.parquet`
|
| 31 |
+
const { file, metadata } = await openFile(url, stats)
|
| 32 |
+
let keys
|
| 33 |
+
await parquetRead({ file, metadata, compressors, columns: ['grid_cell', 'acquired'], rowFormat: 'object',
|
| 34 |
+
onComplete(rows) { keys = rows } })
|
| 35 |
+
let rowStart = -1, rowEnd = -1
|
| 36 |
+
for (let i = 0; i < keys.length; i++) {
|
| 37 |
+
if (keys[i].grid_cell !== cell || ms(keys[i].acquired) !== acquired) { if (rowStart >= 0) break; continue }
|
| 38 |
+
if (rowStart < 0) rowStart = i
|
| 39 |
+
rowEnd = i + 1
|
| 40 |
+
}
|
| 41 |
+
if (rowStart < 0) return null
|
| 42 |
+
let rows
|
| 43 |
+
await parquetRead({ file, metadata, compressors, columns: [step, ...columns], rowStart, rowEnd, rowFormat: 'object',
|
| 44 |
+
onComplete(r) { rows = r } })
|
| 45 |
+
const out = { step: rows.map(r => Number(r[step])) }
|
| 46 |
+
for (const c of columns) out[c] = rows.map(r => (r[c] === null || r[c] === undefined ? null : Number(r[c])))
|
| 47 |
+
return out
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/** Both tables for one sample: {hourly, daily, stats}; a table is null where Core-Weather has no row. */
|
| 51 |
+
export async function loadWeather(cell, timestamp) {
|
| 52 |
+
const stats = newStats()
|
| 53 |
+
const t = timestamp, day = `${t.slice(0, 4)}-${t.slice(4, 6)}-${t.slice(6, 8)}`
|
| 54 |
+
const acquired = Date.UTC(+t.slice(0, 4), +t.slice(4, 6) - 1, +t.slice(6, 8), +t.slice(9, 11), +t.slice(11, 13), +t.slice(13, 15))
|
| 55 |
+
const [hourly, daily] = await Promise.all([
|
| 56 |
+
readTable('hourly', day, cell, acquired, HOURLY.map(c => c[0]), 'step_h', stats),
|
| 57 |
+
readTable('daily', day, cell, acquired, DAILY.map(c => c[0]), 'step_d', stats),
|
| 58 |
+
])
|
| 59 |
+
stats.read = performance.now() - stats.t0
|
| 60 |
+
return { hourly, daily, stats }
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/** One block per variable: name, source and the value at the acquisition above a chart of the steps; then the
|
| 64 |
+
* steps' axis. */
|
| 65 |
+
export function weatherRows(table, which) {
|
| 66 |
+
if (!table) return []
|
| 67 |
+
const spec = which === 'hourly' ? HOURLY : DAILY, unit = which === 'hourly' ? 'h' : 'd'
|
| 68 |
+
const blocks = spec.map(([column, label, source, units, show, mark]) => {
|
| 69 |
+
const values = table[column].map(v => (v === null || Number.isNaN(v) ? null : show(v)))
|
| 70 |
+
const at0 = values[table.step.indexOf(0)]
|
| 71 |
+
const block = document.createElement('div')
|
| 72 |
+
block.className = 'wx-row'
|
| 73 |
+
block.innerHTML = `<div class="wx-top"><span class="wx-label">${label} <small>${source}</small></span>` +
|
| 74 |
+
`<span class="wx-value">${at0 === null || at0 === undefined ? '–' : `${fmt(at0)}<small> ${units}</small>`}</span></div>` +
|
| 75 |
+
chart(table.step, values, mark)
|
| 76 |
+
return block
|
| 77 |
+
})
|
| 78 |
+
const s0 = table.step[0], s1 = table.step[table.step.length - 1]
|
| 79 |
+
const axis = document.createElement('div')
|
| 80 |
+
axis.className = 'wx-axis'
|
| 81 |
+
axis.innerHTML = `<span>${s0} ${unit}</span><span>acquisition</span><span>+${s1} ${unit}</span>`
|
| 82 |
+
return [...blocks, axis]
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
const fmt = v => (Math.abs(v) >= 100 ? v.toFixed(0) : Math.abs(v) >= 10 ? v.toFixed(1) : v.toFixed(2))
|
| 86 |
+
|
| 87 |
+
/** A chart of one series over the steps: its own y range (from zero for bars), the acquisition as a vertical line
|
| 88 |
+
* with a dot, the range's ends written at the right. Drawn at 300 x 44 and scaled as a whole (no distortion). */
|
| 89 |
+
function chart(steps, values, mark) {
|
| 90 |
+
const W = 300, H = 44, L = 2, R = 40, T = 5, B = 5
|
| 91 |
+
const known = values.filter(v => v !== null)
|
| 92 |
+
const empty = `<svg class="wx-chart" viewBox="0 0 ${W} ${H}" aria-hidden="true"><text x="${(W - R) / 2}" y="${H / 2 + 4}" class="wx-none">no data</text></svg>`
|
| 93 |
+
if (!known.length) return empty
|
| 94 |
+
let lo = Math.min(...known), hi = Math.max(...known)
|
| 95 |
+
if (mark === 'bars') lo = 0
|
| 96 |
+
if (hi === lo) hi = lo + 1
|
| 97 |
+
const s0 = steps[0], s1 = steps[steps.length - 1], step = (W - L - R) / (s1 - s0)
|
| 98 |
+
const x = s => L + (s - s0) * step
|
| 99 |
+
const y = v => H - B - ((v - lo) / (hi - lo)) * (H - T - B)
|
| 100 |
+
let body = ''
|
| 101 |
+
if (mark === 'bars') {
|
| 102 |
+
const w = Math.max(step * 0.7, 1)
|
| 103 |
+
steps.forEach((s, i) => {
|
| 104 |
+
if (values[i] > 0) body += `<rect x="${(x(s) - w / 2).toFixed(1)}" y="${y(values[i]).toFixed(1)}" width="${w.toFixed(1)}" height="${(y(0) - y(values[i])).toFixed(1)}" rx="1"/>`
|
| 105 |
+
})
|
| 106 |
+
body = `<line x1="${L}" x2="${W - R}" y1="${y(0)}" y2="${y(0)}" class="wx-base"/><g class="wx-bars">${body}</g>`
|
| 107 |
+
} else if (mark === 'dots') {
|
| 108 |
+
steps.forEach((s, i) => { if (values[i] !== null) body += `<circle cx="${x(s).toFixed(1)}" cy="${y(values[i]).toFixed(1)}" r="2"/>` })
|
| 109 |
+
body = `<g class="wx-dots">${body}</g>`
|
| 110 |
+
} else {
|
| 111 |
+
const runs = [[]]
|
| 112 |
+
steps.forEach((s, i) => (values[i] === null ? runs.push([]) : runs[runs.length - 1].push([x(s), y(values[i])])))
|
| 113 |
+
for (const run of runs.filter(r => r.length)) {
|
| 114 |
+
const line = run.map(([a, b], k) => `${k ? 'L' : 'M'}${a.toFixed(1)},${b.toFixed(1)}`).join('')
|
| 115 |
+
body += `<path class="wx-area" d="${line}L${run[run.length - 1][0].toFixed(1)},${H - B}L${run[0][0].toFixed(1)},${H - B}Z"/>` +
|
| 116 |
+
`<path class="wx-line" d="${line}"/>`
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
const i0 = steps.indexOf(0), v0 = values[i0]
|
| 120 |
+
const now = `<line x1="${x(0)}" x2="${x(0)}" y1="${T - 3}" y2="${H - B}" class="wx-zero"/>` +
|
| 121 |
+
(v0 !== null && v0 !== undefined ? `<circle cx="${x(0)}" cy="${y(v0).toFixed(1)}" r="3.5" class="wx-now"/>` : '')
|
| 122 |
+
const ends = `<text x="${W}" y="${T + 6}" class="wx-tick">${fmt(hi)}</text><text x="${W}" y="${H - B}" class="wx-tick">${fmt(lo)}</text>`
|
| 123 |
+
return `<svg class="wx-chart" viewBox="0 0 ${W} ${H}" aria-hidden="true">${body}${now}${ends}</svg>`
|
| 124 |
+
}
|
zstd.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// zstd-compressed GeoTIFF tiles (VIIRS, AlphaEarth): geotiff.js 2.1.3 has no decoder for compression 50000, so one
|
| 2 |
+
// is registered here, on fzstd. Importing this module once is enough: geotiff.js keeps the decoder for every file.
|
| 3 |
+
|
| 4 |
+
import { addDecoder, BaseDecoder } from 'https://cdn.jsdelivr.net/npm/geotiff@2.1.3/+esm'
|
| 5 |
+
import { decompress } from 'https://cdn.jsdelivr.net/npm/fzstd@0.1.1/+esm'
|
| 6 |
+
|
| 7 |
+
class ZstdDecoder extends BaseDecoder {
|
| 8 |
+
decodeBlock(buffer) {
|
| 9 |
+
const out = decompress(new Uint8Array(buffer))
|
| 10 |
+
return out.buffer.slice(out.byteOffset, out.byteOffset + out.byteLength)
|
| 11 |
+
}
|
| 12 |
+
}
|
| 13 |
+
addDecoder(50000, () => ZstdDecoder)
|