lunar-toolkit 0.1.0.dev6__tar.gz → 0.1.0.dev11__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/PKG-INFO +2 -1
  2. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/README.md +1 -0
  3. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/cli/app.py +14 -4
  4. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/cli/serve.py +13 -0
  5. lunar_toolkit-0.1.0.dev11/lunar_toolkit/static/basemaps/lola_dem_global.tif +0 -0
  6. lunar_toolkit-0.1.0.dev11/lunar_toolkit/static/basemaps/lola_dem_north.tif +0 -0
  7. lunar_toolkit-0.1.0.dev11/lunar_toolkit/static/basemaps/lola_dem_south.tif +0 -0
  8. lunar_toolkit-0.1.0.dev11/lunar_toolkit/static/basemaps/lola_shade_global.jpg +0 -0
  9. lunar_toolkit-0.1.0.dev11/lunar_toolkit/static/basemaps/lola_shade_north.jpg +0 -0
  10. lunar_toolkit-0.1.0.dev11/lunar_toolkit/static/basemaps/lola_shade_south.jpg +0 -0
  11. lunar_toolkit-0.1.0.dev11/lunar_toolkit/static/basemaps/manifest.json +11 -0
  12. lunar_toolkit-0.1.0.dev11/lunar_toolkit/static/basemaps/wac_global.jpg +0 -0
  13. lunar_toolkit-0.1.0.dev11/lunar_toolkit/static/basemaps/wac_north.jpg +0 -0
  14. lunar_toolkit-0.1.0.dev11/lunar_toolkit/static/basemaps/wac_south.jpg +0 -0
  15. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/static/index.html +340 -11
  16. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit.egg-info/PKG-INFO +2 -1
  17. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit.egg-info/SOURCES.txt +10 -0
  18. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/pyproject.toml +2 -2
  19. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_app.py +12 -0
  20. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_static.py +2 -0
  21. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/LICENSE +0 -0
  22. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/__init__.py +0 -0
  23. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/asp.py +0 -0
  24. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/cli/__init__.py +0 -0
  25. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/cli/_jobs.py +0 -0
  26. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/cli/_status.py +0 -0
  27. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/cli/_utils.py +0 -0
  28. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/cli/build_db.py +0 -0
  29. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/cli/download.py +0 -0
  30. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/cli/kaguya.py +0 -0
  31. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/cli/ode_search.py +0 -0
  32. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/cli/pairs.py +0 -0
  33. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/cli/process.py +0 -0
  34. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/cli/query.py +0 -0
  35. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/cli/reference.py +0 -0
  36. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/cli/search.py +0 -0
  37. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/cli/sfs.py +0 -0
  38. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/cli/sfs_report.py +0 -0
  39. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/cli/shadowcam.py +0 -0
  40. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/cli/stereo.py +0 -0
  41. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/cli/workspace.py +0 -0
  42. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/config.py +0 -0
  43. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/constants.py +0 -0
  44. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/csm.py +0 -0
  45. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/downloader.py +0 -0
  46. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/geometry.py +0 -0
  47. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/isis.py +0 -0
  48. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/kaguya/__init__.py +0 -0
  49. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/kaguya/mi.py +0 -0
  50. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/kaguya/tc.py +0 -0
  51. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/lroc/__init__.py +0 -0
  52. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/lroc/cumindex.py +0 -0
  53. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/lroc/nac.py +0 -0
  54. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/observation.py +0 -0
  55. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/ode.py +0 -0
  56. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/provenance.py +0 -0
  57. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/reference.py +0 -0
  58. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/selection.py +0 -0
  59. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/sfs.py +0 -0
  60. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/sfs_products.py +0 -0
  61. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/sfs_report.py +0 -0
  62. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/shadowcam/__init__.py +0 -0
  63. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/shadowcam/sc.py +0 -0
  64. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/stereo.py +0 -0
  65. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/stereo_dem.py +0 -0
  66. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/tools.py +0 -0
  67. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit/workspace.py +0 -0
  68. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit.egg-info/dependency_links.txt +0 -0
  69. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit.egg-info/entry_points.txt +0 -0
  70. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit.egg-info/requires.txt +0 -0
  71. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/lunar_toolkit.egg-info/top_level.txt +0 -0
  72. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/setup.cfg +0 -0
  73. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_aoi.py +0 -0
  74. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_build.py +0 -0
  75. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_config.py +0 -0
  76. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_csm.py +0 -0
  77. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_download.py +0 -0
  78. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_footprints.py +0 -0
  79. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_integration.py +0 -0
  80. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_kaguya.py +0 -0
  81. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_kaguya_mi.py +0 -0
  82. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_lbl.py +0 -0
  83. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_ode.py +0 -0
  84. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_reference.py +0 -0
  85. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_selection.py +0 -0
  86. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_sfs.py +0 -0
  87. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_sfs_products.py +0 -0
  88. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_sfs_report.py +0 -0
  89. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_shadowcam.py +0 -0
  90. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_stereo.py +0 -0
  91. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_tool_env.py +0 -0
  92. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_tools.py +0 -0
  93. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_update.py +0 -0
  94. {lunar_toolkit-0.1.0.dev6 → lunar_toolkit-0.1.0.dev11}/tests/test_workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lunar-toolkit
3
- Version: 0.1.0.dev6
3
+ Version: 0.1.0.dev11
4
4
  Summary: Find, download and process lunar orbital imagery (LROC, ShadowCam, Kaguya TC) and build stereo and shape-from-shading DEMs with ISIS + ASP
5
5
  License-Expression: BSD-3-Clause
6
6
  Keywords: moon,lroc,shadowcam,kaguya,isis,ames-stereo-pipeline,shape-from-shading,dem
@@ -51,6 +51,7 @@ Dynamic: license-file
51
51
  [![coverage](https://img.shields.io/endpoint?url=https://alexmaietta.github.io/lunar-toolkit/coverage/badge.json)](https://alexmaietta.github.io/lunar-toolkit/coverage/)
52
52
  [![pypi](https://img.shields.io/pypi/v/lunar-toolkit)](https://pypi.org/project/lunar-toolkit/)
53
53
  [![license](https://img.shields.io/badge/license-BSD_3--Clause-blue)](LICENSE)
54
+ [![app](https://img.shields.io/badge/app-open_in_browser-yellow)](https://alexmaietta.github.io/lunar-toolkit/browser/)
54
55
 
55
56
  # Lunar Toolkit
56
57
 
@@ -2,6 +2,7 @@
2
2
  [![coverage](https://img.shields.io/endpoint?url=https://alexmaietta.github.io/lunar-toolkit/coverage/badge.json)](https://alexmaietta.github.io/lunar-toolkit/coverage/)
3
3
  [![pypi](https://img.shields.io/pypi/v/lunar-toolkit)](https://pypi.org/project/lunar-toolkit/)
4
4
  [![license](https://img.shields.io/badge/license-BSD_3--Clause-blue)](LICENSE)
5
+ [![app](https://img.shields.io/badge/app-open_in_browser-yellow)](https://alexmaietta.github.io/lunar-toolkit/browser/)
5
6
 
6
7
  # Lunar Toolkit
7
8
 
@@ -54,7 +54,7 @@ Examples
54
54
 
55
55
  The server runs on this machine (a free port on 127.0.0.1 by default) and stops when
56
56
  the window closes. Window backends, first available wins (--backend auto):
57
- webview pywebview: Qt WebEngine on Linux (pip install PyQt6 PyQt6-WebEngine qtpy,
57
+ webview pywebview: Qt WebEngine on Linux (pip install "lunar-toolkit[app]",
58
58
  or PyGObject + WebKitGTK), Edge WebView2 on Windows, WebKit on macOS
59
59
  chromium Chrome / Chromium / Edge / Brave in --app mode (own profile in the cache dir)
60
60
  browser the default browser (Ctrl-C stops the server)
@@ -128,13 +128,16 @@ def chromium_binary(platform: str | None = None) -> str | None:
128
128
  return None
129
129
 
130
130
 
131
+ INSTALL_HINT = 'pip install "lunar-toolkit[app]"'
132
+
133
+
131
134
  def choose_backend(requested: str = "auto", platform: str | None = None) -> tuple[str, str | None]:
132
135
  """``(backend, detail)``: the pywebview GUI name, the Chromium binary, or None for the browser."""
133
136
  if requested in ("auto", "webview"):
134
137
  gui = webview_gui(platform)
135
138
  if gui:
136
139
  return "webview", gui
137
- if requested in ("auto", "chromium", "webview"):
140
+ if requested in ("auto", "chromium"):
138
141
  binary = chromium_binary(platform)
139
142
  if binary:
140
143
  return "chromium", binary
@@ -171,6 +174,12 @@ def _run_chromium(url: str, binary: str) -> None:
171
174
  def run(args: argparse.Namespace) -> int:
172
175
  from .serve import make_server, print_banner
173
176
 
177
+ backend, detail = choose_backend(args.backend)
178
+ if args.backend == "webview" and backend != "webview":
179
+ print(
180
+ f"No native window toolkit for pywebview here; {INSTALL_HINT}, or use --backend chromium / browser."
181
+ )
182
+ return 1
174
183
  try:
175
184
  server, cfg = make_server(args)
176
185
  except (ValueError, OSError) as exc:
@@ -179,7 +188,8 @@ def run(args: argparse.Namespace) -> int:
179
188
  url = print_banner(server, cfg, args.host, TITLE)
180
189
  thread = threading.Thread(target=server.serve_forever, name="lunar-server", daemon=True)
181
190
  thread.start()
182
- backend, detail = choose_backend(args.backend)
191
+ if backend != "webview" and args.backend == "auto":
192
+ print(f" note : no native window toolkit; {INSTALL_HINT} for one")
183
193
  try:
184
194
  if backend == "webview" and detail:
185
195
  print(f" window : pywebview ({detail})")
@@ -193,7 +203,7 @@ def run(args: argparse.Namespace) -> int:
193
203
  print(f" window : {Path(detail).name} --app")
194
204
  _run_chromium(url, detail)
195
205
  return 0
196
- print(" window : none found (pip install PyQt6 PyQt6-WebEngine qtpy); opening a browser tab")
206
+ print(" window : no Chrome / Chromium / Edge either; opening a browser tab")
197
207
  print(" Ctrl-C to stop.\n")
198
208
  webbrowser.open(url)
199
209
  while thread.is_alive():
@@ -29,6 +29,7 @@ COMMAND = "serve"
29
29
 
30
30
  _STATIC_DIR = Path(__file__).parent.parent / "static"
31
31
  _INDEX = _STATIC_DIR / "index.html"
32
+ _BASEMAPS = _STATIC_DIR / "basemaps"
32
33
 
33
34
  _IMAGE_TYPES = {
34
35
  ".tif": "image/tiff",
@@ -44,7 +45,11 @@ _RELAY_HOSTS = (
44
45
  "pds.lroc.im-ldi.com",
45
46
  "pds.shadowcam.im-ldi.com",
46
47
  "data.darts.isas.jaxa.jp",
48
+ "pgda.gsfc.nasa.gov",
47
49
  )
50
+ # Hosts relayed only under these path prefixes (PGDA: the LOLA DEMs for the map's contours)
51
+ _RELAY_PATHS = {"pgda.gsfc.nasa.gov": "/data/LOLA_20mpp/"}
52
+ _BASEMAP_TYPES = {**_IMAGE_TYPES, ".json": "application/json"}
48
53
  _MAX_LISTED_IMAGES = 2000
49
54
  RELAY_MAX = 6
50
55
  RELAY_WAIT = 30.0
@@ -189,6 +194,7 @@ def _relay_allowed(url: str) -> bool:
189
194
  and port is None
190
195
  and not u.username
191
196
  and Path(u.path).suffix.lower() in _IMAGE_TYPES
197
+ and u.path.startswith(_RELAY_PATHS.get(u.hostname, "/"))
192
198
  )
193
199
 
194
200
 
@@ -382,6 +388,13 @@ class LunarHandler(http.server.BaseHTTPRequestHandler):
382
388
  },
383
389
  body,
384
390
  )
391
+ elif path.startswith("/basemaps/"):
392
+ name = path[len("/basemaps/") :]
393
+ ctype = _BASEMAP_TYPES.get(Path(name).suffix.lower())
394
+ if not ctype or "/" in name or name.startswith("."):
395
+ self._send_code(404)
396
+ return
397
+ self._serve(_BASEMAPS / name, ctype, body)
385
398
  elif path == "/api/images":
386
399
  self._json(_list_images(cfg.images_dir) if cfg.images_dir else [], body)
387
400
  elif path.startswith("/images/") and cfg.images_dir is not None:
@@ -0,0 +1,11 @@
1
+ {
2
+ "wac_global": {"file": "wac_global.jpg", "proj": "eqc", "bounds": [-180, -90, 180, 90]},
3
+ "lola_shade_global": {"file": "lola_shade_global.jpg", "proj": "eqc", "bounds": [-180, -90, 180, 90]},
4
+ "lola_dem_global": {"file": "lola_dem_global.tif", "proj": "eqc"},
5
+ "wac_south": {"file": "wac_south.jpg", "proj": "S", "bounds": [-609600, -609600, 609600, 609600]},
6
+ "wac_north": {"file": "wac_north.jpg", "proj": "N", "bounds": [-609600, -609600, 609600, 609600]},
7
+ "lola_shade_south": {"file": "lola_shade_south.jpg", "proj": "S", "bounds": [-609600, -609600, 609600, 609600]},
8
+ "lola_shade_north": {"file": "lola_shade_north.jpg", "proj": "N", "bounds": [-609600, -609600, 609600, 609600]},
9
+ "lola_dem_south": {"file": "lola_dem_south.tif", "proj": "S"},
10
+ "lola_dem_north": {"file": "lola_dem_north.tif", "proj": "N"}
11
+ }
@@ -36,6 +36,7 @@
36
36
  --fp: #1f4a8a;
37
37
  --fp-sel: #c62828;
38
38
  --region: #2e7d32;
39
+ --contour: #9a6a3a;
39
40
  --view-bg: #303030;
40
41
  --view-fg: #e8e8e8;
41
42
  }
@@ -178,15 +179,21 @@
178
179
  /* Footprint plot */
179
180
  .mapbox { height: 100%; position: relative; }
180
181
  .map { display: flex; flex-direction: column; height: 100%; }
181
- .map svg { display: block; flex: 1 1 0; min-height: 0; width: 100%; touch-action: none; user-select: none; cursor: crosshair; }
182
+ .map .plotarea { position: relative; flex: 1 1 0; min-height: 0; }
183
+ .map svg { position: absolute; inset: 0; display: block; width: 100%; height: 100%; touch-action: none; user-select: none; cursor: crosshair; }
184
+ .map canvas { position: absolute; background: var(--plot-bg); pointer-events: none; }
185
+ .mapbox.has-basemap .plot { fill: none; }
186
+ .map .contour { fill: none; stroke: var(--contour); stroke-width: 0.7; stroke-opacity: 0.85; vector-effect: non-scaling-stroke; pointer-events: none; }
187
+ .map .contour.major { stroke-width: 1.3; }
188
+ .panehead select { width: auto; height: 19px; padding: 0 20px 0 4px; font-size: 12px; background-position: right 10px top 7px, right 6px top 7px, 0 0; }
182
189
  .map svg.dragging { cursor: grabbing; }
183
190
  .map .plot { fill: var(--plot-bg); }
184
191
  .map .frame { fill: none; stroke: var(--plot-fg); stroke-width: 1; shape-rendering: crispEdges; }
185
192
  .map .tick { fill: none; stroke: var(--plot-fg); stroke-width: 1; shape-rendering: crispEdges; }
186
193
  .map .grid { fill: none; stroke: var(--plot-grid); stroke-width: 1; }
187
- .map .fp { fill: var(--fp); fill-opacity: 0.08; fill-rule: evenodd; stroke: var(--fp); stroke-width: 0.8; cursor: pointer; }
188
- .map .fp:hover { fill-opacity: 0.25; stroke-width: 1.3; }
189
- .map .fp.sel { fill: var(--fp-sel); fill-opacity: 0.3; stroke: var(--fp-sel); stroke-width: 1.8; }
194
+ .map .fp { fill: var(--fp); fill-opacity: 0.06; fill-rule: evenodd; stroke: var(--fp); stroke-opacity: 0.55; stroke-width: 0.8; cursor: pointer; }
195
+ .map .fp:hover { fill-opacity: 0.22; stroke-opacity: 0.9; stroke-width: 1.3; }
196
+ .map .fp.sel { fill: var(--fp-sel); fill-opacity: 0.25; stroke: var(--fp-sel); stroke-opacity: 1; stroke-width: 1.8; }
190
197
  .mapbox.hide-fp .fp, .mapbox.hide-region .region { display: none; }
191
198
  .panehead .toggle { display: inline-flex; align-items: center; gap: 3px; margin-right: 8px; white-space: nowrap; }
192
199
  .map .region { fill: none; fill-rule: evenodd; stroke: var(--region); stroke-width: 1.4; stroke-dasharray: 6 3; pointer-events: none; }
@@ -1236,12 +1243,19 @@
1236
1243
  const NS = 'http://www.w3.org/2000/svg';
1237
1244
  const svg = document.createElementNS(NS, 'svg');
1238
1245
  const reset = el('button', { class: 'tool', type: 'button', title: 'Reset view (double click)' }, 'Reset view');
1246
+ const canvas = el('canvas');
1247
+ const spin = el('span', { class: 'loading', hidden: '' });
1248
+ const cinfo = el('span', { class: 'info' });
1249
+ const pick = el('select', { 'aria-label': 'Basemap', title: 'Basemap under the footprints' });
1250
+ const cbox = el('input', { type: 'checkbox' });
1239
1251
  const bar = el('div', { class: 'panehead' },
1240
1252
  el('span', { class: 'title' }, 'Footprints'),
1241
1253
  el('span', { class: 'info' }, hemi ? `Polar stereographic (${hemi === 'S' ? 'south' : 'north'}), R = ${MOON_RADIUS_KM} km`
1242
1254
  : 'Lon/lat (equirectangular)', ` | ${items.length} footprint(s) | wheel zoom, drag pan`),
1243
- el('span', { class: 'right' }, layerToggle(container, 'fp', 'Footprints'), layerToggle(container, 'region', 'Region'), reset));
1244
- container.replaceChildren(el('div', { class: 'map' }, bar, svg));
1255
+ cinfo, spin,
1256
+ el('span', { class: 'right' }, pick, el('label', { class: 'toggle', title: 'Elevation contours (LOLA)' }, cbox, ' Contours'),
1257
+ layerToggle(container, 'fp', 'Footprints'), layerToggle(container, 'region', 'Region'), reset));
1258
+ container.replaceChildren(el('div', { class: 'map' }, bar, el('div', { class: 'plotarea' }, canvas, svg)));
1245
1259
  const clipId = `clip-${container.id}`;
1246
1260
  const M = MARGIN;
1247
1261
 
@@ -1269,6 +1283,7 @@
1269
1283
  if (x1 - x0 < minSpan) [x0, x1] = [(x0 + x1 - minSpan) / 2, (x0 + x1 + minSpan) / 2];
1270
1284
  if (y1 - y0 < minSpan) [y0, y1] = [(y0 + y1 - minSpan) / 2, (y0 + y1 + minSpan) / 2];
1271
1285
  const home = { cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, w: x1 - x0, h: y1 - y0 };
1286
+ const under = mapUnderlay({ container, hemi, kx, canvas, spin, cinfo, pick, cbox, home, redraw: () => redraw() });
1272
1287
  // SW x SH: whole svg; W x H: plot area inside the axis margins
1273
1288
  let view = null, SW = 0, SH = 0, W = 0, H = 0, sel = -1, touched = false;
1274
1289
  const fit = () => {
@@ -1364,11 +1379,13 @@
1364
1379
  if (sel >= 0) order.push(sel);
1365
1380
  svg.innerHTML = `<defs><clipPath id="${clipId}"><rect width="${W}" height="${H}"/></clipPath></defs>`
1366
1381
  + `<g transform="translate(${M.l} ${M.t})"><rect class="plot" width="${W}" height="${H}"/>`
1367
- + `<g clip-path="url(#${clipId})">${a.grid}`
1382
+ + `<g clip-path="url(#${clipId})">${a.grid}<g class="cslot"/>`
1368
1383
  + order.map((i) => `<path class="fp${i === sel ? ' sel' : ''}" data-i="${i}" d="${pathD(fps[i])}"><title>${esc(items[i].title)}</title></path>`).join('')
1369
1384
  + (reg.length ? `<path class="region" d="${pathD(reg)}"/>` : '')
1370
1385
  + `</g><rect class="frame" x="0.5" y="0.5" width="${W - 1}" height="${H - 1}"/>`
1371
1386
  + `<path class="tick" d="${a.ticks}"/>${a.labels}</g>`;
1387
+ svg.querySelector('.cslot').replaceWith(under.contours);
1388
+ under.draw(view, W, H);
1372
1389
  }
1373
1390
 
1374
1391
  let frame = 0;
@@ -1454,9 +1471,9 @@
1454
1471
  return items;
1455
1472
  }
1456
1473
 
1457
- // Newest detail on top of the details pane
1474
+ // The details pane shows the selected product only
1458
1475
  function showDetail(container, block) {
1459
- container.prepend(block);
1476
+ container.replaceChildren(block);
1460
1477
  container.scrollTop = 0;
1461
1478
  }
1462
1479
 
@@ -1949,8 +1966,8 @@
1949
1966
  const MAX_OVERVIEW_PX = 2048; // remote images without a level this small get no preview
1950
1967
  const STRIP_TILE = 512; // block size for untiled (striped) TIFFs
1951
1968
  const TILE_REQUESTS = 4; // concurrent tile reads (PDS rate-limits with HTTP 429)
1952
- // pds.mcp.nasa.gov drops its CORS header for browser requests; lunar serve relays those hosts
1953
- const RELAY_FIRST = ['pds.mcp.nasa.gov', 'pds.lroc.im-ldi.com'];
1969
+ // pds.mcp.nasa.gov drops its CORS header for browser requests and PGDA sends none; lunar serve relays those hosts
1970
+ const RELAY_FIRST = ['pds.mcp.nasa.gov', 'pds.lroc.im-ldi.com', 'pgda.gsfc.nasa.gov'];
1954
1971
  let geotiffLib = null;
1955
1972
  let serveConfig = { relay: [], images: false };
1956
1973
  let localImages = []; // paths from `lunar serve --images`
@@ -1961,6 +1978,9 @@
1961
1978
  }));
1962
1979
  const hostOf = (url) => { try { return new URL(url, window.location.href).hostname; } catch { return ''; } };
1963
1980
  const relayable = (url) => /^https:/i.test(url) && serveConfig.relay.includes(hostOf(url));
1981
+ // The PDS image hosts block reads from other sites; without the lunar serve relay they cannot load
1982
+ const BLOCKED_TEXT = 'LROC images need lunar app or lunar serve, because the PDS image host blocks reads from other websites.';
1983
+ const blocked = (url) => serveConfig.checked && RELAY_FIRST.includes(hostOf(url)) && !relayable(url);
1964
1984
  const relayUrl = (url) => new URL(`remote?url=${encodeURIComponent(url)}`, window.location.href).href;
1965
1985
  const readUrl = (url, viaRelay = RELAY_FIRST.includes(hostOf(url))) => (viaRelay && relayable(url) ? relayUrl(url) : url);
1966
1986
  const abortError = () => new DOMException('Aborted', 'AbortError');
@@ -2270,6 +2290,7 @@
2270
2290
  previewCtl?.abort();
2271
2291
  const ctl = previewCtl = new AbortController();
2272
2292
  const spec = previewSource(prod);
2293
+ if (spec.url && blocked(spec.url)) return box.replaceChildren(el('span', { class: 'msg' }, BLOCKED_TEXT));
2273
2294
  const msg = (text, cls = 'msg') => box.replaceChildren(el('span', { class: cls }, text));
2274
2295
  msg(`Loading preview (${spec.label})`, 'msg busy');
2275
2296
  try {
@@ -2611,6 +2632,308 @@
2611
2632
  }
2612
2633
  }
2613
2634
 
2635
+ // Basemaps and contours under the footprint map: a bundled overview (basemaps/, works offline),
2636
+ // then COG tiles streamed when zoomed in past it. The map's lon/lat (x scaled by kx) and polar
2637
+ // stereographic metres are the rasters' own coordinates, so placement is an affine scale.
2638
+ const BM_MOON = 'https://asc-pds-services.s3.us-west-2.amazonaws.com/wms_basemaps3/Moon/';
2639
+ const BM_SP = 'https://asc-pds-services.s3.us-west-2.amazonaws.com/mosaic/Lunar_safed/LMAP/SouthPolar/';
2640
+ const PGDA_80S = 'https://pgda.gsfc.nasa.gov/data/LOLA_20mpp/LDEM_80S_20MPP_ADJ.TIF';
2641
+ // [bundled overview, streamed COG] per map projection and basemap
2642
+ const BASEMAPS = {
2643
+ eqc: { wac: ['wac_global', `${BM_MOON}LRO_WAC_Mosaic_Global_303ppd_v3_cog.tif`],
2644
+ shade: ['lola_shade_global', `${BM_MOON}LRO_LOLA_Shade_Global_128ppd_v04_cog.tif`] },
2645
+ S: { wac: ['wac_south', `${BM_MOON}south_pole/WAC_morphology_SP_June2013_100m_8bit_cog.tif`],
2646
+ shade: ['lola_shade_south', `${BM_MOON}south_pole/LRO_LOLA_ClrShade_SPole75_30m_v04_pct_cog.tif`],
2647
+ shade5: ['lola_shade_south', `${BM_SP}ldem_87s_5mpp_hillshade_cog.tif`],
2648
+ nac: ['wac_south', `${BM_SP}LRO_LROC_NAC_SPOLE-90_Mosaic_1m_controlled_cog.tif`] },
2649
+ N: { wac: ['wac_north', `${BM_MOON}north_pole/WAC_morphology_NP_June2013_100m_8bit_cog.tif`],
2650
+ shade: ['lola_shade_north', `${BM_MOON}north_pole/LRO_LOLA_ClrShade_NPole75_30m_v04_pct_cog.tif`] },
2651
+ };
2652
+ const BASEMAP_LABELS = { none: 'No basemap', wac: 'WAC mosaic', shade: 'LOLA hillshade', shade5: 'LOLA 5 m hillshade', nac: 'LROC NAC 1 m mosaic' };
2653
+ // Contour DEMs, finest first, the bundled overview last (PGDA only through the relay)
2654
+ const CONTOUR_DEMS = {
2655
+ eqc: [`${BM_MOON}LRO_LOLA_DEM_Global_256ppd_v06_16bit_cog.tif`, 'lola_dem_global'],
2656
+ S: [`${BM_SP}ldem_87s_5mpp_cog.tif`, PGDA_80S, 'lola_dem_south'],
2657
+ N: ['lola_dem_north'],
2658
+ };
2659
+ const BM_STRETCH = { min: 0, max: 255, gamma: 1 };
2660
+ const mapPrefs = { basemap: 'wac', contours: false }; // remembered across searches
2661
+ const bmOpened = new Map(); // ref -> { promise, src, pending, failed }
2662
+ let bmManifest = null, bmNotify = null;
2663
+ const bmUrl = (file) => new URL(`basemaps/${file}`, window.location.href).href;
2664
+
2665
+ // A bundled overview (manifest name) or a remote COG; failures are retried after a minute
2666
+ function bmLoad(ref) {
2667
+ let e = bmOpened.get(ref);
2668
+ if (e?.failed && Date.now() - e.failed > 60000) e = null;
2669
+ if (e) return e;
2670
+ e = { pending: true };
2671
+ bmOpened.set(ref, e);
2672
+ e.promise = (async () => {
2673
+ let url = ref, meta = null;
2674
+ if (!/^https:/.test(ref)) {
2675
+ bmManifest ??= fetch(bmUrl('manifest.json')).then((r) => (r.ok ? r.json() : {})).catch(() => ({}));
2676
+ meta = (await bmManifest)[ref];
2677
+ if (!meta) throw new Error(`No bundled ${ref}`);
2678
+ url = bmUrl(meta.file);
2679
+ }
2680
+ if (meta?.bounds) {
2681
+ const img = await loadImg(url);
2682
+ return { key: url, pic: img, w0: img.naturalWidth, h0: img.naturalHeight, box: meta.bounds };
2683
+ }
2684
+ const src = await openTiff({ url });
2685
+ const first = src.levels[0].img;
2686
+ src.box ??= first.getBoundingBox();
2687
+ src.palette ??= Number(first.fileDirectory.getValue('PhotometricInterpretation')) === 3;
2688
+ return src;
2689
+ })().then((src) => (e.src = src), () => { e.failed = Date.now(); return null; })
2690
+ .finally(() => { e.pending = false; bmNotify?.(); });
2691
+ return e;
2692
+ }
2693
+
2694
+ async function loadBasemapTile({ key, src, li, tx, ty }, signal) {
2695
+ const L = src.levels[li], [x0, y0] = [tx * L.tw, ty * L.th];
2696
+ const win = [x0, y0, Math.min(x0 + L.tw, L.w), Math.min(y0 + L.th, L.h)];
2697
+ const [w, h] = [win[2] - win[0], win[3] - win[1]];
2698
+ const r = src.palette && !L.virtual ? await L.img.readRGB({ window: win, interleave: false, signal })
2699
+ : await readWindow(src, L, win, signal);
2700
+ const canvas = paint(Array.from(r), w, h, BM_STRETCH, src.palette ? null : src.noData);
2701
+ tileAdd(key, { canvas, w, h, bytes: 0 }, w * h * 4);
2702
+ }
2703
+
2704
+ // Marching squares over grid parts { z, w, h, x(i), y(j) }: SVG paths (minor, major every 5th)
2705
+ function contourPaths(parts, noData, step, X, Y) {
2706
+ const out = [[], []];
2707
+ for (const { z, w, h, x, y } of parts) {
2708
+ const seg = (k, [i0, j0], [i1, j1]) => out[k % 5 === 0 ? 1 : 0].push(
2709
+ `M${X(x(i0)).toFixed(1)} ${Y(y(j0)).toFixed(1)}L${X(x(i1)).toFixed(1)} ${Y(y(j1)).toFixed(1)}`);
2710
+ for (let j = 0; j < h - 1; j++) {
2711
+ for (let i = 0; i < w - 1; i++) {
2712
+ const a = z[j * w + i], b = z[j * w + i + 1], c = z[(j + 1) * w + i + 1], d = z[(j + 1) * w + i];
2713
+ if (isFill(a, noData) || isFill(b, noData) || isFill(c, noData) || isFill(d, noData)) continue;
2714
+ const k1 = Math.floor(Math.max(a, b, c, d) / step);
2715
+ for (let k = Math.ceil(Math.min(a, b, c, d) / step); k <= k1; k++) {
2716
+ const v = k * step, p = [];
2717
+ if ((a < v) !== (b < v)) p.push([i + (v - a) / (b - a), j]);
2718
+ if ((b < v) !== (c < v)) p.push([i + 1, j + (v - b) / (c - b)]);
2719
+ if ((d < v) !== (c < v)) p.push([i + (v - d) / (c - d), j + 1]);
2720
+ if ((a < v) !== (d < v)) p.push([i, j + (v - a) / (d - a)]);
2721
+ if (p.length >= 2) seg(k, p[0], p[1]);
2722
+ if (p.length === 4) seg(k, p[2], p[3]);
2723
+ }
2724
+ }
2725
+ }
2726
+ }
2727
+ return out.map((a) => a.join(''));
2728
+ }
2729
+
2730
+ // The basemap canvas and the contour layer of one footprint map
2731
+ function mapUnderlay({ container, hemi, kx, canvas, spin, cinfo, pick, cbox, home, redraw }) {
2732
+ bmNotify?.abort?.();
2733
+ const proj = hemi ?? 'eqc', sc = hemi ? 1 : kx, NS = 'http://www.w3.org/2000/svg';
2734
+ const ctl = new AbortController();
2735
+ const st = { wanted: [], inflight: new Set(), failed: new Map(), timer: 0, ctimer: 0, ckey: '', gen: 0, cbusy: false, contour: null };
2736
+ const notify = () => { if (container.isConnected) redraw(); };
2737
+ notify.abort = () => ctl.abort();
2738
+ bmNotify = notify;
2739
+ const contours = document.createElementNS(NS, 'g');
2740
+ const paths = ['contour', 'contour major'].map((c) => {
2741
+ const p = document.createElementNS(NS, 'path');
2742
+ p.setAttribute('class', c);
2743
+ contours.append(p);
2744
+ return p;
2745
+ });
2746
+ // Raster extent in map data coordinates (x right, y down)
2747
+ const geom = (s) => { const [x0, y0, x1, y1] = s.box; return { X0: x0 * sc, X1: x1 * sc, Y0: -y1, Y1: -y0 }; };
2748
+ // x offsets of the wrapped lon/lat copies of g overlapping [vx0, vx1]
2749
+ const copies = (g, vx0, vx1) => {
2750
+ if (hemi) return [0];
2751
+ const p = 360 * sc, n0 = Math.ceil((vx0 - g.X1) / p), n1 = Math.floor((vx1 - g.X0) / p);
2752
+ return n1 - n0 > 8 ? [] : Array.from({ length: n1 - n0 + 1 }, (_, i) => (n0 + i) * p);
2753
+ };
2754
+ const choices = Object.keys(BASEMAPS[proj]).filter((c) => !['shade5', 'nac'].includes(c)
2755
+ || Math.max(Math.abs(home.cx) - home.w / 2, Math.abs(home.cy) - home.h / 2) < 182107);
2756
+ const choice = () => (choices.includes(mapPrefs.basemap) ? mapPrefs.basemap
2757
+ : { shade5: 'shade', nac: 'wac' }[mapPrefs.basemap] ?? 'none');
2758
+ pick.replaceChildren(...['none', ...choices].map((c) => el('option', { value: c }, BASEMAP_LABELS[c])));
2759
+ pick.value = choice();
2760
+ container.classList.toggle('has-basemap', choice() !== 'none');
2761
+ pick.addEventListener('change', () => {
2762
+ mapPrefs.basemap = pick.value;
2763
+ container.classList.toggle('has-basemap', pick.value !== 'none');
2764
+ redraw();
2765
+ });
2766
+ cbox.checked = mapPrefs.contours;
2767
+ cbox.addEventListener('change', () => {
2768
+ mapPrefs.contours = cbox.checked;
2769
+ st.ckey = '';
2770
+ if (!cbox.checked) { st.gen++; st.cbusy = false; st.contour = null; paths.forEach((p) => p.removeAttribute('d')); cinfo.textContent = ''; }
2771
+ redraw();
2772
+ });
2773
+
2774
+ function status() {
2775
+ const n = st.inflight.size + st.wanted.length, opening = [...bmOpened.values()].some((e) => e.pending);
2776
+ spin.hidden = !(n || opening || st.cbusy);
2777
+ spin.textContent = n || opening ? 'Loading basemap...' : 'Loading contours...';
2778
+ }
2779
+
2780
+ function pump() {
2781
+ while (st.inflight.size < TILE_REQUESTS && st.wanted.length) {
2782
+ const job = st.wanted.shift();
2783
+ if (st.inflight.has(job.key) || tiles.has(job.key) || (st.failed.get(job.key) ?? 0) >= 3) continue;
2784
+ st.inflight.add(job.key);
2785
+ loadBasemapTile(job, ctl.signal).then(notify, () => { st.failed.set(job.key, (st.failed.get(job.key) ?? 0) + 1); })
2786
+ .finally(() => { st.inflight.delete(job.key); if (!ctl.signal.aborted) pump(); });
2787
+ }
2788
+ status();
2789
+ }
2790
+
2791
+ function draw(view, W, H) {
2792
+ const r = dpr();
2793
+ Object.assign(canvas.style, { left: `${MARGIN.l}px`, top: `${MARGIN.t}px`, width: `${W}px`, height: `${H}px` });
2794
+ if (canvas.width !== Math.round(W * r) || canvas.height !== Math.round(H * r)) [canvas.width, canvas.height] = [Math.round(W * r), Math.round(H * r)];
2795
+ const ctx = canvas.getContext('2d');
2796
+ ctx.setTransform(r, 0, 0, r, 0, 0);
2797
+ ctx.clearRect(0, 0, W, H);
2798
+ const vx0 = view.cx - W / 2 / view.k, vx1 = view.cx + W / 2 / view.k;
2799
+ const vy0 = view.cy - H / 2 / view.k, vy1 = view.cy + H / 2 / view.k;
2800
+ // Source pixels of img (iw x ih over extent g, shifted by dx) clipped to the canvas
2801
+ const put = (img, iw, ih, g, dx, sharp) => {
2802
+ const ax = ((g.X1 - g.X0) / iw) * view.k, ay = ((g.Y1 - g.Y0) / ih) * view.k;
2803
+ const ox = W / 2 + (g.X0 + dx - view.cx) * view.k, oy = H / 2 + (g.Y0 - view.cy) * view.k;
2804
+ const u0 = Math.max(0, Math.floor(-ox / ax)), u1 = Math.min(iw, Math.ceil((W - ox) / ax));
2805
+ const v0 = Math.max(0, Math.floor(-oy / ay)), v1 = Math.min(ih, Math.ceil((H - oy) / ay));
2806
+ if (u1 <= u0 || v1 <= v0) return;
2807
+ const [px0, py0] = [Math.round(ox + u0 * ax), Math.round(oy + v0 * ay)];
2808
+ ctx.imageSmoothingEnabled = !sharp;
2809
+ ctx.drawImage(img, u0, v0, u1 - u0, v1 - v0, px0, py0, Math.round(ox + u1 * ax) - px0, Math.round(oy + v1 * ay) - py0);
2810
+ };
2811
+ const want = [];
2812
+ const stack = BASEMAPS[proj][choice()];
2813
+ if (stack) {
2814
+ const [b, c] = stack.map((ref) => bmLoad(ref).src);
2815
+ let bpx = Infinity, inside = !hemi;
2816
+ if (b) {
2817
+ const g = geom(b);
2818
+ for (const dx of copies(g, vx0, vx1)) put(b.pic, b.w0, b.h0, g, dx, false);
2819
+ bpx = (g.Y1 - g.Y0) / b.h0;
2820
+ inside ||= vx0 >= g.X0 && vx1 <= g.X1 && vy0 >= g.Y0 && vy1 <= g.Y1;
2821
+ }
2822
+ const g = c && geom(c);
2823
+ // Stream when the overview is magnified, or the view reaches past it
2824
+ if (c && (!hemi || (vx1 > g.X0 && vx0 < g.X1)) && vy1 > g.Y0 && vy0 < g.Y1) {
2825
+ const py = (g.Y1 - g.Y0) / c.h0, target = targetLevel(c, view.k * py);
2826
+ if (py * c.levels[target].fy * 1.5 < bpx || !inside) {
2827
+ for (let li = c.levels.length - 1; li >= target; li--) {
2828
+ const L = c.levels[li], ux = (g.X1 - g.X0) / L.w, uy = (g.Y1 - g.Y0) / L.h;
2829
+ const [ty0, ty1] = [Math.max(0, Math.floor((vy0 - g.Y0) / uy / L.th)), Math.min(Math.ceil(L.h / L.th) - 1, Math.floor((vy1 - g.Y0) / uy / L.th))];
2830
+ for (const dx of copies(g, vx0, vx1)) {
2831
+ const [tx0, tx1] = [Math.max(0, Math.floor((vx0 - g.X0 - dx) / ux / L.tw)), Math.min(Math.ceil(L.w / L.tw) - 1, Math.floor((vx1 - g.X0 - dx) / ux / L.tw))];
2832
+ for (let ty = ty0; ty <= ty1; ty++) {
2833
+ for (let tx = tx0; tx <= tx1; tx++) {
2834
+ const key = `bm|${c.key}|${li}|${tx}|${ty}`, t = tileGet(key);
2835
+ const tg = { X0: g.X0 + tx * L.tw * ux, Y0: g.Y0 + ty * L.th * uy };
2836
+ [tg.X1, tg.Y1] = [g.X0 + Math.min((tx + 1) * L.tw, L.w) * ux, g.Y0 + Math.min((ty + 1) * L.th, L.h) * uy];
2837
+ if (t) put(t.canvas, t.w, t.h, tg, dx, li === target && view.k * uy * r >= 1.5);
2838
+ else if (li === target) {
2839
+ want.push({ key, src: c, li, tx, ty, d: Math.hypot((tg.X0 + tg.X1) / 2 + dx - view.cx, (tg.Y0 + tg.Y1) / 2 - view.cy) });
2840
+ }
2841
+ }
2842
+ }
2843
+ }
2844
+ }
2845
+ }
2846
+ }
2847
+ }
2848
+ st.wanted = want.sort((a, b) => a.d - b.d);
2849
+ clearTimeout(st.timer);
2850
+ st.timer = setTimeout(pump, 80);
2851
+ placeContours(view, W, H);
2852
+ status();
2853
+ }
2854
+
2855
+ function placeContours(view, W, H) {
2856
+ const c = st.contour;
2857
+ if (c) {
2858
+ const f = view.k / c.k;
2859
+ contours.setAttribute('transform', `translate(${(W / 2 + (c.cx - view.cx) * view.k - (f * c.W) / 2).toFixed(2)} `
2860
+ + `${(H / 2 + (c.cy - view.cy) * view.k - (f * c.H) / 2).toFixed(2)}) scale(${f})`);
2861
+ }
2862
+ const key = `${view.cx}|${view.cy}|${view.k}|${W}|${H}`;
2863
+ if (!mapPrefs.contours || key === st.ckey) return;
2864
+ st.ckey = key;
2865
+ clearTimeout(st.ctimer);
2866
+ st.ctimer = setTimeout(() => computeContours({ ...view }, W, H), 300);
2867
+ }
2868
+
2869
+ // DEM samples about 3 screen px apart over the view, from the coarsest adequate level
2870
+ async function demGrid(src, view, W, H, want) {
2871
+ const g = geom(src);
2872
+ let li = 0;
2873
+ src.levels.forEach((L, i) => { if (((g.Y1 - g.Y0) / L.h) <= want) li = i; });
2874
+ const L = src.levels[li], ux = (g.X1 - g.X0) / L.w, uy = (g.Y1 - g.Y0) / L.h;
2875
+ const vx0 = view.cx - W / 2 / view.k, vx1 = view.cx + W / 2 / view.k;
2876
+ const vy0 = view.cy - H / 2 / view.k, vy1 = view.cy + H / 2 / view.k;
2877
+ const r0 = Math.max(0, Math.floor((vy0 - g.Y0) / uy) - 1), r1 = Math.min(L.h, Math.ceil((vy1 - g.Y0) / uy) + 1);
2878
+ const parts = [];
2879
+ for (const dx of copies(g, vx0, vx1)) {
2880
+ const c0 = Math.max(0, Math.floor((vx0 - g.X0 - dx) / ux) - 1), c1 = Math.min(L.w, Math.ceil((vx1 - g.X0 - dx) / ux) + 1);
2881
+ if (c1 - c0 < 2 || r1 - r0 < 2) continue;
2882
+ const z = (await readWindow({ ...src, samples: [0] }, L, [c0, r0, c1, r1], ctl.signal))[0];
2883
+ parts.push({ z, w: c1 - c0, h: r1 - r0, x: (i) => g.X0 + dx + (c0 + i + 0.5) * ux, y: (j) => g.Y0 + (r0 + j + 0.5) * uy });
2884
+ }
2885
+ return { parts, mpp: hemi ? uy : uy * RAD * MOON_RADIUS_KM * 1000 };
2886
+ }
2887
+
2888
+ async function computeContours(view, W, H) {
2889
+ const gen = ++st.gen;
2890
+ st.cbusy = true;
2891
+ status();
2892
+ try {
2893
+ const want = 3 / view.k;
2894
+ const vx0 = view.cx - W / 2 / view.k, vx1 = view.cx + W / 2 / view.k;
2895
+ const vy0 = view.cy - H / 2 / view.k, vy1 = view.cy + H / 2 / view.k;
2896
+ const refs = CONTOUR_DEMS[proj].filter((ref) => ref !== PGDA_80S || relayable(ref));
2897
+ const bundled = await bmLoad(refs.at(-1)).promise;
2898
+ const res = bundled ? (geom(bundled).Y1 - geom(bundled).Y0) / bundled.h0 : Infinity;
2899
+ let grid = null, name = '';
2900
+ for (const ref of refs) {
2901
+ const last = ref === refs.at(-1);
2902
+ if (last ? !bundled : res <= want) continue;
2903
+ const src = last ? bundled : await bmLoad(ref).promise;
2904
+ if (gen !== st.gen) return;
2905
+ const g = src && geom(src);
2906
+ if (!src || (!last && hemi && !(vx0 >= g.X0 && vx1 <= g.X1 && vy0 >= g.Y0 && vy1 <= g.Y1))) continue;
2907
+ try {
2908
+ grid = await demGrid(src, view, W, H, want);
2909
+ grid.noData = src.noData;
2910
+ name = fileName(ref.startsWith('https:') ? ref : bmUrl(ref)).replace(/(_cog)?\.tif$/i, '');
2911
+ break;
2912
+ } catch (err) {
2913
+ if (ctl.signal.aborted) return;
2914
+ }
2915
+ }
2916
+ if (gen !== st.gen) return;
2917
+ let lo = Infinity, hi = -Infinity;
2918
+ for (const { z } of grid?.parts ?? []) for (const v of z) if (!isFill(v, grid.noData)) { if (v < lo) lo = v; if (v > hi) hi = v; }
2919
+ if (!(hi > lo)) { st.contour = null; paths.forEach((p) => p.removeAttribute('d')); cinfo.textContent = grid ? '| no relief in view' : ''; return; }
2920
+ const step = step125(hi - lo, 10);
2921
+ const X = (x) => W / 2 + (x - view.cx) * view.k, Y = (y) => H / 2 + (y - view.cy) * view.k;
2922
+ const d = contourPaths(grid.parts, grid.noData, step, X, Y);
2923
+ paths.forEach((p, i) => p.setAttribute('d', d[i]));
2924
+ st.contour = { ...view, W, H };
2925
+ contours.removeAttribute('transform');
2926
+ const fmtM = (m) => (m >= 1000 ? `${+(m / 1000).toPrecision(2)} km` : `${+m.toPrecision(2)} m`);
2927
+ cinfo.textContent = `| contours ${step} m, bold ${5 * step} m (${name}, ${fmtM(grid.mpp)}/px)`;
2928
+ redraw();
2929
+ } finally {
2930
+ if (gen === st.gen) { st.cbusy = false; status(); }
2931
+ }
2932
+ }
2933
+
2934
+ return { draw, contours };
2935
+ }
2936
+
2614
2937
  // Pixel readout: 1-based sample / line at full resolution (ISIS convention); the value comes
2615
2938
  // from the full-resolution tile, fetched after the pointer rests, meanwhile from a coarser level
2616
2939
  function pixelAt(src, sx, sy) {
@@ -2717,6 +3040,11 @@
2717
3040
  const ctl = V.ctl = new AbortController();
2718
3041
  V.spec = spec;
2719
3042
  fillChoices();
3043
+ if (spec.url && blocked(spec.url)) {
3044
+ V.failedSpec = spec;
3045
+ viewMessage(BLOCKED_TEXT);
3046
+ return;
3047
+ }
2720
3048
  viewMessage(`Loading ${spec.label ?? spec.file?.name ?? ''}...`);
2721
3049
  try {
2722
3050
  const src = await (spec.kind === 'img' ? openPicture(spec, ctl.signal) : openTiff(spec));
@@ -2880,6 +3208,7 @@
2880
3208
  if (list.ok) localImages = await list.json();
2881
3209
  }
2882
3210
  } catch { /* not lunar serve */ }
3211
+ serveConfig.checked = true;
2883
3212
  fillChoices();
2884
3213
  const url = new URLSearchParams(window.location.search).get('image');
2885
3214
  if (url) {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lunar-toolkit
3
- Version: 0.1.0.dev6
3
+ Version: 0.1.0.dev11
4
4
  Summary: Find, download and process lunar orbital imagery (LROC, ShadowCam, Kaguya TC) and build stereo and shape-from-shading DEMs with ISIS + ASP
5
5
  License-Expression: BSD-3-Clause
6
6
  Keywords: moon,lroc,shadowcam,kaguya,isis,ames-stereo-pipeline,shape-from-shading,dem
@@ -51,6 +51,7 @@ Dynamic: license-file
51
51
  [![coverage](https://img.shields.io/endpoint?url=https://alexmaietta.github.io/lunar-toolkit/coverage/badge.json)](https://alexmaietta.github.io/lunar-toolkit/coverage/)
52
52
  [![pypi](https://img.shields.io/pypi/v/lunar-toolkit)](https://pypi.org/project/lunar-toolkit/)
53
53
  [![license](https://img.shields.io/badge/license-BSD_3--Clause-blue)](LICENSE)
54
+ [![app](https://img.shields.io/badge/app-open_in_browser-yellow)](https://alexmaietta.github.io/lunar-toolkit/browser/)
54
55
 
55
56
  # Lunar Toolkit
56
57
 
@@ -56,6 +56,16 @@ lunar_toolkit/lroc/nac.py
56
56
  lunar_toolkit/shadowcam/__init__.py
57
57
  lunar_toolkit/shadowcam/sc.py
58
58
  lunar_toolkit/static/index.html
59
+ lunar_toolkit/static/basemaps/lola_dem_global.tif
60
+ lunar_toolkit/static/basemaps/lola_dem_north.tif
61
+ lunar_toolkit/static/basemaps/lola_dem_south.tif
62
+ lunar_toolkit/static/basemaps/lola_shade_global.jpg
63
+ lunar_toolkit/static/basemaps/lola_shade_north.jpg
64
+ lunar_toolkit/static/basemaps/lola_shade_south.jpg
65
+ lunar_toolkit/static/basemaps/manifest.json
66
+ lunar_toolkit/static/basemaps/wac_global.jpg
67
+ lunar_toolkit/static/basemaps/wac_north.jpg
68
+ lunar_toolkit/static/basemaps/wac_south.jpg
59
69
  tests/test_aoi.py
60
70
  tests/test_app.py
61
71
  tests/test_build.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lunar-toolkit"
7
- version = "0.1.0.dev6"
7
+ version = "0.1.0.dev11"
8
8
  description = "Find, download and process lunar orbital imagery (LROC, ShadowCam, Kaguya TC) and build stereo and shape-from-shading DEMs with ISIS + ASP"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -69,7 +69,7 @@ where = ["."]
69
69
  include = ["lunar_toolkit*"]
70
70
 
71
71
  [tool.setuptools.package-data]
72
- lunar_toolkit = ["static/*.html"]
72
+ lunar_toolkit = ["static/*.html", "static/basemaps/*"]
73
73
 
74
74
  [tool.pytest.ini_options]
75
75
  testpaths = ["tests"]
@@ -106,6 +106,17 @@ def test_config_reports_admin_for_loopback_only(server, remote):
106
106
  assert _json(server, "/api/config?token=wrong")[1]["admin"] is False
107
107
 
108
108
 
109
+ def test_basemaps_are_served_with_range(server):
110
+ code, headers, raw = _req(server, "/basemaps/manifest.json")
111
+ assert code == 200 and headers["Content-Type"] == "application/json"
112
+ manifest = json.loads(raw)
113
+ assert all((serve._BASEMAPS / m["file"]).is_file() for m in manifest.values())
114
+ code, headers, raw = _req(server, "/basemaps/lola_dem_global.tif", headers={"Range": "bytes=0-3"})
115
+ assert code == 206 and raw[:2] == b"II"
116
+ for bad in ("/basemaps/../index.html", "/basemaps/x.txt", "/basemaps/missing.jpg"):
117
+ assert _req(server, bad)[0] == 404, bad
118
+
119
+
109
120
  def test_loopback_is_admin_but_needs_a_local_host_header(server):
110
121
  assert _json(server, "/api/config")[1]["admin"] is True
111
122
  assert _json(server, "/api/config", headers={"Host": "evil.example:80"})[1]["admin"] is False
@@ -447,6 +458,7 @@ def test_backend_selection(monkeypatch):
447
458
  assert app.choose_backend("auto", "linux") == ("browser", None)
448
459
  binary[0] = "/usr/bin/chromium"
449
460
  assert app.choose_backend("auto", "linux") == ("chromium", "/usr/bin/chromium")
461
+ assert app.choose_backend("webview", "linux") == ("browser", None)
450
462
  present |= {"webview", "gi"}
451
463
  assert app.choose_backend("auto", "linux") == ("webview", "gtk")
452
464
  present |= {"qtpy", "PyQt6.QtWebEngineWidgets"}
@@ -112,7 +112,9 @@ def test_relay_only_pds_image_files():
112
112
  ok = "https://pds.mcp.nasa.gov/data/store/img/lunar_reconnaissance_orbiter/pds4/lroc/x/M1_pyr.tif"
113
113
  assert _relay_allowed(ok)
114
114
  assert _relay_allowed("https://data.darts.isas.jaxa.jp/pub/pds3/x/browse/large/TC1.jpg")
115
+ assert _relay_allowed("https://pgda.gsfc.nasa.gov/data/LOLA_20mpp/LDEM_80S_20MPP_ADJ.TIF")
115
116
  for bad in (
117
+ "https://pgda.gsfc.nasa.gov/other/a.tif",
116
118
  ok.replace("https:", "http:"),
117
119
  "https://example.com/a.tif",
118
120
  "https://pds.mcp.nasa.gov.evil.com/a.tif",