mikonvergence commited on
Commit
8a28d42
·
verified ·
1 Parent(s): 5ad4e5a

Sync from GitHub 33dc1e2

Browse files
Files changed (9) hide show
  1. README.md +12 -2
  2. aef.js +374 -0
  3. app.js +182 -35
  4. build_index.py +121 -9
  5. download.js +93 -0
  6. index.html +36 -4
  7. viirs.js +46 -0
  8. weather.js +124 -0
  9. 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: Open (full window, zoom and pan) or Show on map (over its footprint, opacity slider): every image
34
  is 10,680 m square in its UTM zone, centred on the sample's centre_lat, centre_lon, so corners need no band read
35
  View menu: the stored thumbnail (default), composites (S2: true colour, false colour, SWIR, one stretch for the three
36
  bands; S1: VV, VH, VV/VH in dB; DEM: elevation in colour) and every band (grey, 2-98% stretch, value under the
37
  cursor in the full window) -> bands.js: the band columns (LZW GeoTIFFs) of the sample's row group, geotiff.js
 
 
 
 
 
 
 
 
 
 
38
  Downloads (download.js): the shown band as stored (.tif); every band, the thumbnail and metadata.json (.zip); a
39
  Python snippet that reads the same row group from Hugging Face
40
  in the background: the other datasets' footprints once the first is drawn; after a click, the same cell's samples
@@ -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) or on the map over its footprint (corners from its UTM centre)
 
10
  // -> downloads of the bands as stored (download.js)
11
 
12
  import * as maplibregl from 'https://cdn.jsdelivr.net/npm/maplibre-gl@6.14.0/dist/maplibre-gl.mjs'
@@ -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 { downloadAll, downloadBand, pythonSnippet } from './download.js'
 
 
 
 
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: 12,
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
- $('dataset-link').href = `https://huggingface.co/datasets/Major-TOM/Core-${name}`
208
- $('dataset-link').textContent = `huggingface.co/datasets/Major-TOM/Core-${name}`
 
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.part}/${sample.parquet_row}`, fresh = !thumbs.has(key)
258
- if (fresh) thumbs.set(key, loadThumbnail(state.datasets[name], sample).catch(e => { thumbs.delete(key); throw e }))
259
- return [thumbs.get(key), fresh]
 
 
 
 
 
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
- showThumbnail(null, 'Loading the thumbnail…')
 
325
  try {
326
- const [loading, fresh] = thumbnailOf(state.name, sample), t = await loading
 
 
 
 
 
 
 
 
 
 
 
327
  if (ticket !== pick.ticket) return
328
- pick.thumbnail = { ...t, sample }
329
- pick.shown = { src: t.src, values: null, sample, label: 'Thumbnail' }
 
 
330
  showThumbnail(t.src)
331
  showOnMap()
332
  meta(sample, t.productId, info)
333
- buildViews(name, info, sample, ticket)
334
- $('pick-stats').textContent = `Thumbnail: ${kb(t.stats.bytes)} in ${reqs(t.stats.requests)} · ${secs(t.stats.read)}` +
 
 
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 when "Show on map" is ticked; otherwise none. */
366
  function showOnMap() {
367
- const t = pick.shown, on = $('on-map').checked && t && pick.cell
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': Number($('opacity').value) / 100, 'raster-fade-duration': 0 } }, 'coast')
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') { pick.shown = { src: t.src, values: null, sample: t.sample, label: 'Thumbnail' }; refreshShown(); return }
403
- const view = pick.views.find(v => v.id === id), key = `${state.name}/${t.sample.part}/${t.sample.parquet_row}/${id}`
 
 
 
404
  $('pick-stats').textContent = `Loading ${view.label}…`
405
  const fresh = !renders.has(key), stats = newStats()
406
- if (fresh) renders.set(key, render(state.info, t.sample, view, stats).catch(e => { renders.delete(key); throw e }))
 
 
 
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
- $('dl-band').addEventListener('click', () => downloading($('dl-band'),
462
- () => downloadBand(state.info, state.name, pick.cell.name, pick.thumbnail.sample, shownBand())))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = pythonSnippet(state.info, state.name, pick.cell.name, pick.thumbnail.sample, pick.bands.length ? pick.bands : ['thumbnail'])
 
 
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
- const url = imageUrl(info, sample.part)
539
- 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}`])
 
 
 
 
 
 
 
 
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
- python build_index.py /data/Major-TOM <out folder> (Slurm, .venv311)
 
 
 
 
 
 
 
 
 
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, label in DATASETS.items():
76
- table, base = index(core / f"Core-{name}" / "metadata.parquet", name)
 
 
 
 
 
 
 
 
 
 
 
77
  write(table, out / f"{name}.parquet")
78
- listing[name] = {"label": label, "images": base, "samples": table.num_rows,
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:3])
 
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">Open</button>
186
- <label><input type="checkbox" id="on-map"> Show on map</label>
187
- <label>Opacity <input type="range" id="opacity" min="0" max="100" value="100" aria-label="Opacity on the map"></label>
188
  </div>
189
  <div class="image-actions" id="download-actions" hidden>
190
  <button id="dl-band" title="The shown band as stored: a GeoTIFF">Download band (.tif)</button>
@@ -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)