mikonvergence commited on
Commit
71dc245
·
verified ·
1 Parent(s): 266a3a9

Open full window; show on map; footprints with image centres

Browse files
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 20.5 MB each, S1RTC 12.1 MB, DEM 4.5 MB) |
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:6b4d67e3b790f7f216374a27a3b9289db9fd63e260e4ab9ffc5e9d15ba057347
3
- size 4482569
 
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:3089bd10a123b5052a5ee5a8c2956822f182c7401231b2f67cb9c30a84322ac3
3
- size 12103409
 
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:1cc52c2e3b5ad3cf415b136eaa8cd93761b8fec5a9828d7a00ad88f5d1159752
3
- size 20484283
 
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:1cc52c2e3b5ad3cf415b136eaa8cd93761b8fec5a9828d7a00ad88f5d1159752
3
- size 20484283
 
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