Open full window; show on map; footprints with image centres
Browse files- README.md +3 -1
- app.js +80 -2
- build_index.py +17 -4
- footprints/DEM.parquet +2 -2
- footprints/S1RTC.parquet +2 -2
- footprints/S2L1C.parquet +2 -2
- footprints/S2L2A.parquet +2 -2
- footprints/datasets.json +16 -4
- index.html +30 -0
README.md
CHANGED
|
@@ -30,6 +30,8 @@ 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 |
in the background: the other datasets' footprints once the first is drawn; after a click, the same cell's samples
|
| 34 |
and first thumbnail in the other datasets (kept, so switching dataset shows them at once)
|
| 35 |
```
|
|
@@ -45,7 +47,7 @@ in the background: the other datasets' footprints once the first is drawn; after
|
|
| 45 |
| `parquet.js` | range requests for hyparquet, with bytes and time counted |
|
| 46 |
| `tiles.js`, `labels.js`, `places.json` | copied from the Climate Globe: cells and tiles, place names |
|
| 47 |
| `build_index.py` | Core `metadata.parquet` -> `footprints/<name>.parquet` and `footprints/datasets.json` |
|
| 48 |
-
| `footprints/` | built files, not in git: uploaded to the Space once (S2L2A and S2L1C
|
| 49 |
|
| 50 |
## Reads
|
| 51 |
|
|
|
|
| 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 |
in the background: the other datasets' footprints once the first is drawn; after a click, the same cell's samples
|
| 36 |
and first thumbnail in the other datasets (kept, so switching dataset shows them at once)
|
| 37 |
```
|
|
|
|
| 47 |
| `parquet.js` | range requests for hyparquet, with bytes and time counted |
|
| 48 |
| `tiles.js`, `labels.js`, `places.json` | copied from the Climate Globe: cells and tiles, place names |
|
| 49 |
| `build_index.py` | Core `metadata.parquet` -> `footprints/<name>.parquet` and `footprints/datasets.json` |
|
| 50 |
+
| `footprints/` | built files, not in git: uploaded to the Space once (S2L2A and S2L1C 32.9 MB each, S1RTC 20.6 MB, DEM 14.5 MB) |
|
| 51 |
|
| 52 |
## Reads
|
| 53 |
|
app.js
CHANGED
|
@@ -5,9 +5,11 @@
|
|
| 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 |
|
| 9 |
import * as maplibregl from 'https://cdn.jsdelivr.net/npm/maplibre-gl@6.14.0/dist/maplibre-gl.mjs'
|
| 10 |
import { feature } from 'https://cdn.jsdelivr.net/npm/topojson-client@3.1.0/+esm'
|
|
|
|
| 11 |
import { DATASETS, loadCoverage, samplesAt } from './footprints.js'
|
| 12 |
import { placeLabels } from './labels.js'
|
| 13 |
import { imageUrl, loadThumbnail } from './samples.js'
|
|
@@ -278,13 +280,14 @@ map.on('click', e => {
|
|
| 278 |
$('pick-clear').addEventListener('click', () => {
|
| 279 |
pick.cell = null; pick.ticket++; $('pick').hidden = true
|
| 280 |
map.getSource('selected').setData({ type: 'FeatureCollection', features: [] })
|
|
|
|
| 281 |
})
|
| 282 |
|
| 283 |
/** The selected cell in the shown dataset: its samples, then the first one's thumbnail. */
|
| 284 |
async function pickCell() {
|
| 285 |
const ticket = ++pick.ticket, { row, col } = pick.cell, name = state.name
|
| 286 |
pick.samples = []; pick.thumbnail = null
|
| 287 |
-
showThumbnail(null, 'Looking up the cell…')
|
| 288 |
$('choices').replaceChildren(); $('pick-meta').replaceChildren(); $('pick-stats').textContent = ''
|
| 289 |
let samples
|
| 290 |
try {
|
|
@@ -316,8 +319,9 @@ async function choose(i) {
|
|
| 316 |
try {
|
| 317 |
const [loading, fresh] = thumbnailOf(state.name, sample), t = await loading
|
| 318 |
if (ticket !== pick.ticket) return
|
| 319 |
-
pick.thumbnail = t
|
| 320 |
showThumbnail(t.src)
|
|
|
|
| 321 |
meta(sample, t.productId, info)
|
| 322 |
$('pick-stats').textContent = `Thumbnail: ${kb(t.stats.bytes)} in ${t.stats.requests} requests · ${secs(t.stats.read)}` +
|
| 323 |
(fresh ? '' : ' (loaded before)')
|
|
@@ -329,10 +333,84 @@ async function choose(i) {
|
|
| 329 |
|
| 330 |
function showThumbnail(src, note = '') {
|
| 331 |
$('thumb').hidden = !src
|
|
|
|
| 332 |
if (src) $('thumb').src = src
|
| 333 |
$('thumb-note').textContent = src ? '' : note
|
| 334 |
}
|
| 335 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
/** "20200213T023821" -> "2020-02-13 02:38 UTC". */
|
| 337 |
const when = t => `${t.slice(0, 4)}-${t.slice(4, 6)}-${t.slice(6, 8)} ${t.slice(9, 11)}:${t.slice(11, 13)} UTC`
|
| 338 |
|
|
|
|
| 5 |
// click -> cell -> its samples (footprint rows, page index) -> image file on Hugging Face, one row group,
|
| 6 |
// thumbnail column (samples.js) -> panel
|
| 7 |
// cursor -> cell under it -> outline + name, position, whether it has a sample
|
| 8 |
+
// the shown image -> full window (zoom, pan) or on the map over its footprint (corners from its UTM centre)
|
| 9 |
|
| 10 |
import * as maplibregl from 'https://cdn.jsdelivr.net/npm/maplibre-gl@6.14.0/dist/maplibre-gl.mjs'
|
| 11 |
import { feature } from 'https://cdn.jsdelivr.net/npm/topojson-client@3.1.0/+esm'
|
| 12 |
+
import proj4 from 'https://cdn.jsdelivr.net/npm/proj4@2.12.1/+esm'
|
| 13 |
import { DATASETS, loadCoverage, samplesAt } from './footprints.js'
|
| 14 |
import { placeLabels } from './labels.js'
|
| 15 |
import { imageUrl, loadThumbnail } from './samples.js'
|
|
|
|
| 280 |
$('pick-clear').addEventListener('click', () => {
|
| 281 |
pick.cell = null; pick.ticket++; $('pick').hidden = true
|
| 282 |
map.getSource('selected').setData({ type: 'FeatureCollection', features: [] })
|
| 283 |
+
showOnMap()
|
| 284 |
})
|
| 285 |
|
| 286 |
/** The selected cell in the shown dataset: its samples, then the first one's thumbnail. */
|
| 287 |
async function pickCell() {
|
| 288 |
const ticket = ++pick.ticket, { row, col } = pick.cell, name = state.name
|
| 289 |
pick.samples = []; pick.thumbnail = null
|
| 290 |
+
showThumbnail(null, 'Looking up the cell…'); showOnMap()
|
| 291 |
$('choices').replaceChildren(); $('pick-meta').replaceChildren(); $('pick-stats').textContent = ''
|
| 292 |
let samples
|
| 293 |
try {
|
|
|
|
| 319 |
try {
|
| 320 |
const [loading, fresh] = thumbnailOf(state.name, sample), t = await loading
|
| 321 |
if (ticket !== pick.ticket) return
|
| 322 |
+
pick.thumbnail = { ...t, sample }
|
| 323 |
showThumbnail(t.src)
|
| 324 |
+
showOnMap()
|
| 325 |
meta(sample, t.productId, info)
|
| 326 |
$('pick-stats').textContent = `Thumbnail: ${kb(t.stats.bytes)} in ${t.stats.requests} requests · ${secs(t.stats.read)}` +
|
| 327 |
(fresh ? '' : ' (loaded before)')
|
|
|
|
| 333 |
|
| 334 |
function showThumbnail(src, note = '') {
|
| 335 |
$('thumb').hidden = !src
|
| 336 |
+
$('image-actions').hidden = !src
|
| 337 |
if (src) $('thumb').src = src
|
| 338 |
$('thumb-note').textContent = src ? '' : note
|
| 339 |
}
|
| 340 |
|
| 341 |
+
// ---- the shown image on the map -------------------------------------------------------------------------------
|
| 342 |
+
// Every Core image is 10,680 m square in its UTM zone, centred on the sample's centre_lat, centre_lon (checked
|
| 343 |
+
// against the GeoTIFF bounds), north up: its corners are the centre +- 5,340 m, back in longitude and latitude.
|
| 344 |
+
|
| 345 |
+
const HALF = 5340
|
| 346 |
+
|
| 347 |
+
/** Corners of a sample's image, [lon, lat], from top left clockwise (as MapLibre's image source wants them). */
|
| 348 |
+
function corners(s) {
|
| 349 |
+
const zone = s.epsg % 100, south = s.epsg > 32700
|
| 350 |
+
const utm = proj4(`+proj=utm +zone=${zone}${south ? ' +south' : ''} +datum=WGS84 +units=m +no_defs`)
|
| 351 |
+
const [x, y] = utm.forward([s.centre_lon, s.centre_lat])
|
| 352 |
+
return [[x - HALF, y + HALF], [x + HALF, y + HALF], [x + HALF, y - HALF], [x - HALF, y - HALF]].map(p => utm.inverse(p))
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
/** The shown thumbnail over its footprint when "Show on map" is ticked; otherwise none. */
|
| 356 |
+
function showOnMap() {
|
| 357 |
+
const t = pick.thumbnail, on = $('on-map').checked && t && pick.cell
|
| 358 |
+
if (map.getLayer('sample')) map.removeLayer('sample')
|
| 359 |
+
if (map.getSource('sample')) map.removeSource('sample')
|
| 360 |
+
if (!on) return
|
| 361 |
+
map.addSource('sample', { type: 'image', url: t.src, coordinates: corners(t.sample) })
|
| 362 |
+
map.addLayer({ id: 'sample', type: 'raster', source: 'sample',
|
| 363 |
+
paint: { 'raster-opacity': Number($('opacity').value) / 100, 'raster-fade-duration': 0 } }, 'coast')
|
| 364 |
+
}
|
| 365 |
+
$('on-map').addEventListener('change', showOnMap)
|
| 366 |
+
$('opacity').addEventListener('input', () => {
|
| 367 |
+
if (map.getLayer('sample')) map.setPaintProperty('sample', 'raster-opacity', Number($('opacity').value) / 100)
|
| 368 |
+
})
|
| 369 |
+
window.sampleCorners = () => (pick.thumbnail ? corners(pick.thumbnail.sample) : null) // for automated checks
|
| 370 |
+
|
| 371 |
+
// ---- the shown image in the full window -----------------------------------------------------------------------
|
| 372 |
+
// opens fitted to the window; scroll zooms about the cursor, drag pans, Esc or Close closes
|
| 373 |
+
|
| 374 |
+
const view = { scale: 1, x: 0, y: 0, drag: null }
|
| 375 |
+
|
| 376 |
+
function openImage() {
|
| 377 |
+
if (!pick.thumbnail) return
|
| 378 |
+
const img = $('lightbox-img')
|
| 379 |
+
img.src = pick.thumbnail.src
|
| 380 |
+
$('lightbox-title').textContent = `${pick.cell.name} · ${state.info.label}` +
|
| 381 |
+
(pick.thumbnail.sample.timestamp ? ` · ${when(pick.thumbnail.sample.timestamp)}` : '')
|
| 382 |
+
$('lightbox').hidden = false
|
| 383 |
+
img.decode().then(() => {
|
| 384 |
+
const w = innerWidth, h = innerHeight - 44, s = Math.min(w / img.naturalWidth, h / img.naturalHeight)
|
| 385 |
+
Object.assign(view, { scale: s, x: (w - img.naturalWidth * s) / 2, y: 44 + (h - img.naturalHeight * s) / 2 })
|
| 386 |
+
place()
|
| 387 |
+
})
|
| 388 |
+
}
|
| 389 |
+
const place = () => { $('lightbox-img').style.transform = `translate(${view.x}px, ${view.y}px) scale(${view.scale})` }
|
| 390 |
+
const closeImage = () => { $('lightbox').hidden = true }
|
| 391 |
+
|
| 392 |
+
$('thumb').addEventListener('click', openImage)
|
| 393 |
+
$('open-image').addEventListener('click', openImage)
|
| 394 |
+
$('lightbox-close').addEventListener('click', closeImage)
|
| 395 |
+
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !$('lightbox').hidden) closeImage() })
|
| 396 |
+
$('lightbox').addEventListener('wheel', e => {
|
| 397 |
+
e.preventDefault()
|
| 398 |
+
const k = Math.exp(-e.deltaY * 0.0015), s = Math.min(Math.max(view.scale * k, 0.05), 40)
|
| 399 |
+
view.x = e.clientX - (e.clientX - view.x) * (s / view.scale)
|
| 400 |
+
view.y = e.clientY - (e.clientY - view.y) * (s / view.scale)
|
| 401 |
+
view.scale = s; place()
|
| 402 |
+
}, { passive: false })
|
| 403 |
+
$('lightbox').addEventListener('pointerdown', e => {
|
| 404 |
+
if (e.target.closest('button')) return
|
| 405 |
+
view.drag = [e.clientX - view.x, e.clientY - view.y]
|
| 406 |
+
$('lightbox').classList.add('dragging'); $('lightbox').setPointerCapture(e.pointerId)
|
| 407 |
+
})
|
| 408 |
+
$('lightbox').addEventListener('pointermove', e => {
|
| 409 |
+
if (!view.drag) return
|
| 410 |
+
view.x = e.clientX - view.drag[0]; view.y = e.clientY - view.drag[1]; place()
|
| 411 |
+
})
|
| 412 |
+
$('lightbox').addEventListener('pointerup', () => { view.drag = null; $('lightbox').classList.remove('dragging') })
|
| 413 |
+
|
| 414 |
/** "20200213T023821" -> "2020-02-13 02:38 UTC". */
|
| 415 |
const when = t => `${t.slice(0, 4)}-${t.slice(4, 6)}-${t.slice(6, 8)} ${t.slice(9, 11)}:${t.slice(11, 13)} UTC`
|
| 416 |
|
build_index.py
CHANGED
|
@@ -3,7 +3,9 @@
|
|
| 3 |
<core>/Core-<name>/metadata.parquet (one row per sample: grid_row_u, grid_col_r, parquet_url, parquet_row, ...)
|
| 4 |
-> parquet_url -> part number (every URL is <base>/images/part_NNNNN.parquet; checked)
|
| 5 |
-> rows sorted by grid_row_u, grid_col_r
|
| 6 |
-
-> footprints/<name>.parquet: grid_row_u, grid_col_r, part, parquet_row, timestamp, + cloud_cover / orbit_state
|
|
|
|
|
|
|
| 7 |
row groups of 65,536 rows with statistics and a page index, no value dictionaries (as the climate files):
|
| 8 |
the app reads the two position columns for the coverage map and, for a click, the page holding the cell
|
| 9 |
-> footprints/datasets.json: per dataset its label, the URL base of its image files, its samples
|
|
@@ -31,7 +33,7 @@ def index(metadata: Path, name: str) -> tuple[pa.Table, str]:
|
|
| 31 |
"""The footprint table of one dataset, and the URL base of its image files."""
|
| 32 |
have = pq.read_schema(metadata).names
|
| 33 |
table = pq.read_table(metadata, columns=["grid_row_u", "grid_col_r", "parquet_url", "parquet_row",
|
| 34 |
-
*[c for c in EXTRA if c in have]])
|
| 35 |
base = f"https://huggingface.co/datasets/Major-TOM/Core-{name}/resolve/main/images/"
|
| 36 |
urls = table.column("parquet_url").to_pylist()
|
| 37 |
part = [re.fullmatch(re.escape(base) + r"part_(\d{5})\.parquet", u) for u in urls]
|
|
@@ -41,15 +43,26 @@ def index(metadata: Path, name: str) -> tuple[pa.Table, str]:
|
|
| 41 |
"grid_col_r": pc.cast(table.column("grid_col_r"), pa.int16()),
|
| 42 |
"part": pa.array(np.array([int(m[1]) for m in part], np.int16)),
|
| 43 |
"parquet_row": pc.cast(table.column("parquet_row"), pa.int16()),
|
| 44 |
-
**{c: table.column(c) for c in EXTRA if c in have}
|
|
|
|
|
|
|
|
|
|
| 45 |
if "cloud_cover" in out.column_names:
|
| 46 |
out = out.set_column(out.schema.get_field_index("cloud_cover"), "cloud_cover",
|
| 47 |
pc.cast(out.column("cloud_cover"), pa.float32()))
|
| 48 |
return out.sort_by([("grid_row_u", "ascending"), ("grid_col_r", "ascending")]), base
|
| 49 |
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
def write(table: pa.Table, path: Path) -> None:
|
| 52 |
-
keys = ["grid_row_u", "grid_col_r", "orbit_state"]
|
| 53 |
pq.write_table(table, path, row_group_size=ROW_GROUP, compression="zstd", write_statistics=True,
|
| 54 |
write_page_index=True, use_dictionary=[c for c in keys if c in table.column_names],
|
| 55 |
data_page_size=4096)
|
|
|
|
| 3 |
<core>/Core-<name>/metadata.parquet (one row per sample: grid_row_u, grid_col_r, parquet_url, parquet_row, ...)
|
| 4 |
-> parquet_url -> part number (every URL is <base>/images/part_NNNNN.parquet; checked)
|
| 5 |
-> rows sorted by grid_row_u, grid_col_r
|
| 6 |
+
-> footprints/<name>.parquet: grid_row_u, grid_col_r, part, parquet_row, timestamp, + cloud_cover / orbit_state,
|
| 7 |
+
centre_lat, centre_lon, epsg: every image is 10,680 m square in its UTM zone (epsg), centred there exactly
|
| 8 |
+
(checked against the GeoTIFF bounds), so the app places it on the map without reading a band
|
| 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
|
|
|
|
| 33 |
"""The footprint table of one dataset, and the URL base of its image files."""
|
| 34 |
have = pq.read_schema(metadata).names
|
| 35 |
table = pq.read_table(metadata, columns=["grid_row_u", "grid_col_r", "parquet_url", "parquet_row",
|
| 36 |
+
*[c for c in EXTRA if c in have], "centre_lat", "centre_lon", "crs"])
|
| 37 |
base = f"https://huggingface.co/datasets/Major-TOM/Core-{name}/resolve/main/images/"
|
| 38 |
urls = table.column("parquet_url").to_pylist()
|
| 39 |
part = [re.fullmatch(re.escape(base) + r"part_(\d{5})\.parquet", u) for u in urls]
|
|
|
|
| 43 |
"grid_col_r": pc.cast(table.column("grid_col_r"), pa.int16()),
|
| 44 |
"part": pa.array(np.array([int(m[1]) for m in part], np.int16)),
|
| 45 |
"parquet_row": pc.cast(table.column("parquet_row"), pa.int16()),
|
| 46 |
+
**{c: table.column(c) for c in EXTRA if c in have},
|
| 47 |
+
"centre_lat": pc.cast(table.column("centre_lat"), pa.float32()), # 4e-6 degrees: under 1 m
|
| 48 |
+
"centre_lon": pc.cast(table.column("centre_lon"), pa.float32()),
|
| 49 |
+
"epsg": pa.array(epsg(table.column("crs").to_pylist()), pa.int16())})
|
| 50 |
if "cloud_cover" in out.column_names:
|
| 51 |
out = out.set_column(out.schema.get_field_index("cloud_cover"), "cloud_cover",
|
| 52 |
pc.cast(out.column("cloud_cover"), pa.float32()))
|
| 53 |
return out.sort_by([("grid_row_u", "ascending"), ("grid_col_r", "ascending")]), base
|
| 54 |
|
| 55 |
|
| 56 |
+
def epsg(crs: list[str]) -> np.ndarray:
|
| 57 |
+
"""EPSG codes from the metadata's crs strings ("EPSG:32701"; Core-S1RTC writes "EPSG:EPSG:32752")."""
|
| 58 |
+
codes = np.array([int(c.rsplit(":", 1)[1]) for c in crs])
|
| 59 |
+
if not np.all(((codes > 32600) & (codes <= 32660)) | ((codes > 32700) & (codes <= 32760))):
|
| 60 |
+
raise ValueError("a crs outside the UTM zones (EPSG 32601-32660, 32701-32760)")
|
| 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)
|
footprints/DEM.parquet
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:cfbe11fe9c585e28f2d2176568d5b2de0c1d47253535a5685878e17c7b70991b
|
| 3 |
+
size 14548640
|
footprints/S1RTC.parquet
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f1be77107a4da96a4534bd862b5ce3db5868d21c2e2097830bf72a2f3b102c49
|
| 3 |
+
size 20603249
|
footprints/S2L1C.parquet
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:70d02ba0a6c61266afb0c1341b7b5bf21b05ab835c062c3dbacbd53b9948afef
|
| 3 |
+
size 32944059
|
footprints/S2L2A.parquet
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:70d02ba0a6c61266afb0c1341b7b5bf21b05ab835c062c3dbacbd53b9948afef
|
| 3 |
+
size 32944059
|
footprints/datasets.json
CHANGED
|
@@ -9,7 +9,10 @@
|
|
| 9 |
"part",
|
| 10 |
"parquet_row",
|
| 11 |
"timestamp",
|
| 12 |
-
"cloud_cover"
|
|
|
|
|
|
|
|
|
|
| 13 |
]
|
| 14 |
},
|
| 15 |
"S2L1C": {
|
|
@@ -22,7 +25,10 @@
|
|
| 22 |
"part",
|
| 23 |
"parquet_row",
|
| 24 |
"timestamp",
|
| 25 |
-
"cloud_cover"
|
|
|
|
|
|
|
|
|
|
| 26 |
]
|
| 27 |
},
|
| 28 |
"S1RTC": {
|
|
@@ -35,7 +41,10 @@
|
|
| 35 |
"part",
|
| 36 |
"parquet_row",
|
| 37 |
"timestamp",
|
| 38 |
-
"orbit_state"
|
|
|
|
|
|
|
|
|
|
| 39 |
]
|
| 40 |
},
|
| 41 |
"DEM": {
|
|
@@ -46,7 +55,10 @@
|
|
| 46 |
"grid_row_u",
|
| 47 |
"grid_col_r",
|
| 48 |
"part",
|
| 49 |
-
"parquet_row"
|
|
|
|
|
|
|
|
|
|
| 50 |
]
|
| 51 |
}
|
| 52 |
}
|
|
|
|
| 9 |
"part",
|
| 10 |
"parquet_row",
|
| 11 |
"timestamp",
|
| 12 |
+
"cloud_cover",
|
| 13 |
+
"centre_lat",
|
| 14 |
+
"centre_lon",
|
| 15 |
+
"epsg"
|
| 16 |
]
|
| 17 |
},
|
| 18 |
"S2L1C": {
|
|
|
|
| 25 |
"part",
|
| 26 |
"parquet_row",
|
| 27 |
"timestamp",
|
| 28 |
+
"cloud_cover",
|
| 29 |
+
"centre_lat",
|
| 30 |
+
"centre_lon",
|
| 31 |
+
"epsg"
|
| 32 |
]
|
| 33 |
},
|
| 34 |
"S1RTC": {
|
|
|
|
| 41 |
"part",
|
| 42 |
"parquet_row",
|
| 43 |
"timestamp",
|
| 44 |
+
"orbit_state",
|
| 45 |
+
"centre_lat",
|
| 46 |
+
"centre_lon",
|
| 47 |
+
"epsg"
|
| 48 |
]
|
| 49 |
},
|
| 50 |
"DEM": {
|
|
|
|
| 55 |
"grid_row_u",
|
| 56 |
"grid_col_r",
|
| 57 |
"part",
|
| 58 |
+
"parquet_row",
|
| 59 |
+
"centre_lat",
|
| 60 |
+
"centre_lon",
|
| 61 |
+
"epsg"
|
| 62 |
]
|
| 63 |
}
|
| 64 |
}
|
index.html
CHANGED
|
@@ -82,6 +82,25 @@
|
|
| 82 |
margin-bottom: 10px; display: flex; align-items: center; justify-content: center; color: var(--ink-3); font-size: 13px; }
|
| 83 |
#thumb { width: 100%; height: 100%; object-fit: contain; display: block; }
|
| 84 |
#thumb[hidden] { display: none; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
#choices { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
| 86 |
#choices:empty { display: none; }
|
| 87 |
#choices button { font: 12px var(--mono); padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px;
|
|
@@ -156,6 +175,11 @@
|
|
| 156 |
<div class="pick-name"><b id="pick-cell"></b><span id="pick-pos"></span></div>
|
| 157 |
<div id="choices"></div>
|
| 158 |
<div id="thumb-wrap"><img id="thumb" alt="Thumbnail of the selected sample" hidden><span id="thumb-note"></span></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
<dl id="pick-meta"></dl>
|
| 160 |
<div class="tiny" id="pick-stats"></div>
|
| 161 |
</section>
|
|
@@ -188,6 +212,12 @@
|
|
| 188 |
</aside>
|
| 189 |
</main>
|
| 190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
<script>
|
| 192 |
// A private Space is shown in huggingface.co's frame through a signed link (?__sign=...), and only requests that
|
| 193 |
// carry the signature are let through there: modules imported by other modules would not. So every file of the app
|
|
|
|
| 82 |
margin-bottom: 10px; display: flex; align-items: center; justify-content: center; color: var(--ink-3); font-size: 13px; }
|
| 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; }
|
| 89 |
+
.image-actions button:hover { border-color: var(--mt-blue); color: var(--mt-blue); }
|
| 90 |
+
.image-actions label { display: flex; align-items: center; gap: 6px; color: var(--ink-2); cursor: pointer; }
|
| 91 |
+
.image-actions input[type=checkbox], .image-actions input[type=range] { accent-color: var(--mt-blue); }
|
| 92 |
+
.image-actions input[type=range] { width: 90px; }
|
| 93 |
+
.image-actions[hidden] { display: none; }
|
| 94 |
+
#lightbox { position: fixed; inset: 0; z-index: 10; background: rgba(17,24,39,.88); overflow: hidden; cursor: grab;
|
| 95 |
+
touch-action: none; }
|
| 96 |
+
#lightbox[hidden] { display: none; }
|
| 97 |
+
#lightbox.dragging { cursor: grabbing; }
|
| 98 |
+
#lightbox img { position: absolute; left: 0; top: 0; transform-origin: 0 0; image-rendering: pixelated; user-select: none;
|
| 99 |
+
-webkit-user-drag: none; }
|
| 100 |
+
#lightbox-bar { position: absolute; left: 0; right: 0; top: 0; display: flex; justify-content: space-between; gap: 12px;
|
| 101 |
+
align-items: center; padding: 10px 16px; color: #fff; font: 13px var(--mono); background: rgba(17,24,39,.6); }
|
| 102 |
+
#lightbox-bar button { font: 13px var(--sans); padding: 4px 12px; border: 1px solid rgba(255,255,255,.4); border-radius: 8px;
|
| 103 |
+
background: transparent; color: #fff; cursor: pointer; }
|
| 104 |
#choices { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
| 105 |
#choices:empty { display: none; }
|
| 106 |
#choices button { font: 12px var(--mono); padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px;
|
|
|
|
| 175 |
<div class="pick-name"><b id="pick-cell"></b><span id="pick-pos"></span></div>
|
| 176 |
<div id="choices"></div>
|
| 177 |
<div id="thumb-wrap"><img id="thumb" alt="Thumbnail of the selected sample" hidden><span id="thumb-note"></span></div>
|
| 178 |
+
<div class="image-actions" id="image-actions" hidden>
|
| 179 |
+
<button id="open-image">Open</button>
|
| 180 |
+
<label><input type="checkbox" id="on-map"> Show on map</label>
|
| 181 |
+
<label>Opacity <input type="range" id="opacity" min="0" max="100" value="100" aria-label="Opacity on the map"></label>
|
| 182 |
+
</div>
|
| 183 |
<dl id="pick-meta"></dl>
|
| 184 |
<div class="tiny" id="pick-stats"></div>
|
| 185 |
</section>
|
|
|
|
| 212 |
</aside>
|
| 213 |
</main>
|
| 214 |
|
| 215 |
+
<div id="lightbox" hidden role="dialog" aria-label="Selected image">
|
| 216 |
+
<img id="lightbox-img" alt="">
|
| 217 |
+
<div id="lightbox-bar"><span id="lightbox-title"></span><span>scroll to zoom · drag to pan · Esc to close
|
| 218 |
+
<button id="lightbox-close">Close</button></span></div>
|
| 219 |
+
</div>
|
| 220 |
+
|
| 221 |
<script>
|
| 222 |
// A private Space is shown in huggingface.co's frame through a signed link (?__sign=...), and only requests that
|
| 223 |
// carry the signature are let through there: modules imported by other modules would not. So every file of the app
|