lunar-toolkit 0.1.0.dev13__tar.gz → 0.1.0.dev14__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.dev13 → lunar_toolkit-0.1.0.dev14}/PKG-INFO +1 -1
  2. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/static/index.html +429 -76
  3. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit.egg-info/PKG-INFO +1 -1
  4. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/pyproject.toml +1 -1
  5. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/LICENSE +0 -0
  6. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/README.md +0 -0
  7. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/__init__.py +0 -0
  8. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/asp.py +0 -0
  9. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/cli/__init__.py +0 -0
  10. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/cli/_jobs.py +0 -0
  11. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/cli/_status.py +0 -0
  12. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/cli/_utils.py +0 -0
  13. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/cli/app.py +0 -0
  14. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/cli/build_db.py +0 -0
  15. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/cli/download.py +0 -0
  16. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/cli/kaguya.py +0 -0
  17. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/cli/ode_search.py +0 -0
  18. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/cli/pairs.py +0 -0
  19. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/cli/process.py +0 -0
  20. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/cli/query.py +0 -0
  21. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/cli/reference.py +0 -0
  22. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/cli/search.py +0 -0
  23. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/cli/serve.py +0 -0
  24. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/cli/sfs.py +0 -0
  25. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/cli/sfs_report.py +0 -0
  26. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/cli/shadowcam.py +0 -0
  27. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/cli/stereo.py +0 -0
  28. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/cli/workspace.py +0 -0
  29. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/config.py +0 -0
  30. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/constants.py +0 -0
  31. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/csm.py +0 -0
  32. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/downloader.py +0 -0
  33. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/geometry.py +0 -0
  34. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/isis.py +0 -0
  35. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/kaguya/__init__.py +0 -0
  36. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/kaguya/mi.py +0 -0
  37. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/kaguya/tc.py +0 -0
  38. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/lroc/__init__.py +0 -0
  39. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/lroc/cumindex.py +0 -0
  40. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/lroc/nac.py +0 -0
  41. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/observation.py +0 -0
  42. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/ode.py +0 -0
  43. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/provenance.py +0 -0
  44. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/reference.py +0 -0
  45. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/selection.py +0 -0
  46. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/sfs.py +0 -0
  47. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/sfs_products.py +0 -0
  48. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/sfs_report.py +0 -0
  49. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/shadowcam/__init__.py +0 -0
  50. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/shadowcam/sc.py +0 -0
  51. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/static/basemaps/lola_dem_global.tif +0 -0
  52. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/static/basemaps/lola_dem_north.tif +0 -0
  53. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/static/basemaps/lola_dem_south.tif +0 -0
  54. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/static/basemaps/lola_shade_global.jpg +0 -0
  55. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/static/basemaps/lola_shade_north.jpg +0 -0
  56. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/static/basemaps/lola_shade_south.jpg +0 -0
  57. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/static/basemaps/manifest.json +0 -0
  58. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/static/basemaps/wac_global.jpg +0 -0
  59. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/static/basemaps/wac_north.jpg +0 -0
  60. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/static/basemaps/wac_south.jpg +0 -0
  61. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/stereo.py +0 -0
  62. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/stereo_dem.py +0 -0
  63. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/tools.py +0 -0
  64. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit/workspace.py +0 -0
  65. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit.egg-info/SOURCES.txt +0 -0
  66. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit.egg-info/dependency_links.txt +0 -0
  67. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit.egg-info/entry_points.txt +0 -0
  68. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit.egg-info/requires.txt +0 -0
  69. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/lunar_toolkit.egg-info/top_level.txt +0 -0
  70. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/setup.cfg +0 -0
  71. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_aoi.py +0 -0
  72. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_app.py +0 -0
  73. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_build.py +0 -0
  74. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_config.py +0 -0
  75. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_csm.py +0 -0
  76. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_download.py +0 -0
  77. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_footprints.py +0 -0
  78. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_integration.py +0 -0
  79. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_kaguya.py +0 -0
  80. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_kaguya_mi.py +0 -0
  81. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_lbl.py +0 -0
  82. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_ode.py +0 -0
  83. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_reference.py +0 -0
  84. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_selection.py +0 -0
  85. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_sfs.py +0 -0
  86. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_sfs_products.py +0 -0
  87. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_sfs_report.py +0 -0
  88. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_shadowcam.py +0 -0
  89. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_static.py +0 -0
  90. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_stereo.py +0 -0
  91. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_tool_env.py +0 -0
  92. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_tools.py +0 -0
  93. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/tests/test_update.py +0 -0
  94. {lunar_toolkit-0.1.0.dev13 → lunar_toolkit-0.1.0.dev14}/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.dev13
3
+ Version: 0.1.0.dev14
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
@@ -214,6 +214,7 @@
214
214
  .stylepop select { width: auto; }
215
215
  .mapbox.hide-fp .fp, .mapbox.hide-fp .fpfill, .mapbox.hide-region .region { display: none; }
216
216
  .panehead .toggle { display: inline-flex; align-items: center; gap: 3px; margin-right: 8px; white-space: nowrap; }
217
+ .map .limb { fill: none; stroke: var(--border); stroke-width: 1; pointer-events: none; }
217
218
  .map .region { fill: none; fill-rule: evenodd; stroke: var(--region); stroke-width: 1.4; stroke-dasharray: 6 3; pointer-events: none; }
218
219
  .map text { fill: var(--plot-label); font-size: 11px; pointer-events: none; }
219
220
  .map text.gl { fill: var(--muted); font-size: 10.5px; }
@@ -1051,12 +1052,13 @@
1051
1052
  return replicate ? [...g, ...shiftX(g, 360), ...shiftX(g, -360)] : g;
1052
1053
  }
1053
1054
 
1055
+ // hemi 'S' / 'N' polar stereographic, null lon/lat for the cylindrical map, 'LL' raw lon/lat rings
1054
1056
  function footprintGeometry(ringsList, hemi) {
1055
1057
  const parts = [];
1056
1058
  for (let rings of ringsList) {
1057
1059
  rings = rings.map(openRing);
1058
1060
  if (!rings.length || !rings[0].length) continue;
1059
- parts.push(polygon(hemi ? rings.map((r) => psRing(r, hemi))
1061
+ parts.push(polygon(hemi === 'LL' ? rings : hemi ? rings.map((r) => psRing(r, hemi))
1060
1062
  : shiftTo0360(rings.map((r) => (r.length >= 3 ? llRing(r) : r)))));
1061
1063
  }
1062
1064
  return parts.length ? parts : null;
@@ -1094,7 +1096,7 @@
1094
1096
  span = Math.min(east >= west ? east - west : east - west + 360, 360);
1095
1097
  }
1096
1098
  const ring = boxRing(s, n, west, span);
1097
- return [[hemi ? psRing(ring, hemi) : ring]];
1099
+ return [[hemi && hemi !== 'LL' ? psRing(ring, hemi) : ring]];
1098
1100
  }
1099
1101
 
1100
1102
  function bboxOf(rings) {
@@ -1176,7 +1178,7 @@
1176
1178
  const finite = (...vs) => vs.every((v) => v != null && Number.isFinite(v));
1177
1179
  if (mode === 'aoi') {
1178
1180
  const aoi = loadAoi($(`${p}-aoi`).value);
1179
- return { aoi, region: aoiRegion(aoi), shape: (hemi) => aoiGeometry(aoi, hemi, false) };
1181
+ return { aoi, region: aoiRegion(aoi), shape: (hemi) => (hemi === 'LL' ? aoi.polygons : aoiGeometry(aoi, hemi, false)) };
1180
1182
  }
1181
1183
  if (mode === 'point') {
1182
1184
  const [lat, lon, r] = [`${p}-lat`, `${p}-lon`, `${p}-radius`].map(num);
@@ -1185,7 +1187,7 @@
1185
1187
  const ring = circleRing(lat, lon, radiusKm);
1186
1188
  return {
1187
1189
  region: queryRegion({ point: [lat, lon], radiusKm }),
1188
- shape: (hemi) => [[hemi ? psRing(ring, hemi) : ring.length > 2 ? llRing(ring) : ring]],
1190
+ shape: (hemi) => [[hemi === 'LL' ? ring : hemi ? psRing(ring, hemi) : ring.length > 2 ? llRing(ring) : ring]],
1189
1191
  };
1190
1192
  }
1191
1193
  const [west, south, east, north] = [`${p}-west`, `${p}-south`, `${p}-east`, `${p}-north`].map(num);
@@ -1195,6 +1197,11 @@
1195
1197
  region,
1196
1198
  shape: (hemi) => {
1197
1199
  const [s, n] = region.lat;
1200
+ if (hemi === 'LL') {
1201
+ if (region.span < 360) return [[boxRing(s, n, region.west, region.span)]];
1202
+ const rings = [s, n].filter((la) => Math.abs(la) < 90).map((la) => Array.from({ length: 360 }, (_, i) => [i, la]));
1203
+ return rings.length ? [rings] : [];
1204
+ }
1198
1205
  if (hemi && region.span >= 360) {
1199
1206
  // A full-longitude box is a disc or annulus around the pole
1200
1207
  const circle = (la) => psRing(Array.from({ length: 360 }, (_, i) => [i, la]), hemi);
@@ -1319,10 +1326,119 @@
1319
1326
  else renderMap(map, { hemi: mapHemi(sel.region, []), region: sel, items: [] });
1320
1327
  }
1321
1328
 
1329
+ // Map projections: fwd(lon, lat) -> data [x, y] in metres (y down), null beyond the horizon
1330
+ // (the limb for orthographic); inv([x, y]) -> [lon, lat] or null. Auto keeps the lon/lat map
1331
+ // (x = lon * kx, y = -lat) or polar stereographic poleward of +/-60 deg.
1332
+ const MAP_PROJECTIONS = {
1333
+ auto: 'Auto', eqc: 'Cylindrical',
1334
+ stS: 'Stereographic - south pole', stN: 'Stereographic - north pole', stNear: 'Stereographic - near side', stFar: 'Stereographic - far side',
1335
+ orS: 'Orthographic - south pole', orN: 'Orthographic - north pole', orNear: 'Orthographic - near side', orFar: 'Orthographic - far side',
1336
+ };
1337
+ // Short menu text, to keep the pane head narrow
1338
+ const projMenu = (v) => MAP_PROJECTIONS[v].replace('graphic -', '');
1339
+ const PROJ_CENTRES = { S: [-90, 0], N: [90, 0], Near: [0, 0], Far: [0, 180] };
1340
+
1341
+ function azimuthal(ortho, lat0, lon0, cmin = ortho ? 0 : Math.cos(160 * RAD)) {
1342
+ const R = MOON_RADIUS_KM * 1000, s0 = Math.sin(lat0 * RAD), c0 = Math.cos(lat0 * RAD);
1343
+ // Unit-sphere components in data orientation, and the cosine of the distance from the centre
1344
+ const raw = (lon, lat) => {
1345
+ const p = lat * RAD, d = (lon - lon0) * RAD, cp = Math.cos(p);
1346
+ return [cp * Math.sin(d), -(c0 * Math.sin(p) - s0 * cp * Math.cos(d)), s0 * Math.sin(p) + c0 * cp * Math.cos(d)];
1347
+ };
1348
+ const fwd = (lon, lat) => {
1349
+ const [x, y, cc] = raw(lon, lat);
1350
+ if (cc < cmin - 1e-12) return null;
1351
+ const f = ortho ? R : (2 * R) / (1 + cc);
1352
+ return [f * x, f * y];
1353
+ };
1354
+ const rOfC = ortho ? (c) => R * Math.sin(Math.min(c, Math.PI / 2)) : (c) => 2 * R * Math.tan(c / 2);
1355
+ const cOfR = ortho ? (r) => (r <= R ? Math.asin(r / R) : NaN) : (r) => 2 * Math.atan(r / (2 * R));
1356
+ const inv = ([x, y]) => {
1357
+ y = -y;
1358
+ const r = Math.hypot(x, y), c = cOfR(r);
1359
+ if (!(c <= Math.acos(cmin) + 1e-9)) return null;
1360
+ if (r < 1e-9) return [mod(lon0, 360), lat0];
1361
+ const sc = Math.sin(c), cc = Math.cos(c);
1362
+ const lat = Math.asin(Math.max(-1, Math.min(1, cc * s0 + (y * sc * c0) / r))) * DEG;
1363
+ return [mod(lon0 + Math.atan2(x * sc, r * c0 * cc - y * s0 * sc) * DEG, 360), lat];
1364
+ };
1365
+ return { kind: ortho ? 'ortho' : 'stere', cmin, raw, fwd, inv, rOfC, cOfR, rMax: rOfC(Math.acos(cmin)),
1366
+ cosc: (lon, lat) => raw(lon, lat)[2], hemi: lat0 === -90 ? 'S' : lat0 === 90 ? 'N' : null };
1367
+ }
1368
+
1369
+ // pj.raster: the basemap family drawn natively (affine), else the map is reprojected
1370
+ function mapProjection(choice, hemi) {
1371
+ const az = /^(st|or)/.test(choice) && choice in MAP_PROJECTIONS;
1372
+ if (choice === 'eqc' || (!az && !hemi)) return { id: 'eqc', kind: 'eqc', auto: choice !== 'eqc', raster: 'eqc', hemi: null };
1373
+ if (!az) {
1374
+ return { ...azimuthal(false, hemi === 'S' ? -90 : 90, 0, Math.cos(179 * RAD)), id: `ps${hemi}`, auto: true, raster: hemi, gratCap: 90, rMax: Infinity };
1375
+ }
1376
+ const [la, lo] = PROJ_CENTRES[choice.slice(2)];
1377
+ const pj = { ...azimuthal(choice.startsWith('or'), la, lo), id: choice };
1378
+ return { ...pj, raster: pj.kind === 'stere' ? pj.hemi : null, gratCap: Math.acos(pj.cmin) * DEG };
1379
+ }
1380
+
1381
+ // Points along great circles at most step deg apart (footprint edges, which may cross a pole)
1382
+ function densifyGC(ring, step) {
1383
+ const v = ([lo, la]) => [Math.cos(la * RAD) * Math.cos(lo * RAD), Math.cos(la * RAD) * Math.sin(lo * RAD), Math.sin(la * RAD)];
1384
+ const out = [];
1385
+ ring.forEach((p, i) => {
1386
+ const a = v(p), b = v(ring[(i + 1) % ring.length]);
1387
+ const d = Math.acos(Math.max(-1, Math.min(1, a[0] * b[0] + a[1] * b[1] + a[2] * b[2]))), s = Math.sin(d);
1388
+ out.push(p);
1389
+ for (let j = 1, n = Math.ceil((d * DEG) / step); j < n && s > 1e-9; j++) {
1390
+ const f0 = Math.sin(d * (1 - j / n)) / s, f1 = Math.sin((d * j) / n) / s;
1391
+ const [x, y, z] = [0, 1, 2].map((c) => f0 * a[c] + f1 * b[c]);
1392
+ out.push([Math.atan2(y, x) * DEG, Math.asin(Math.max(-1, Math.min(1, z))) * DEG]);
1393
+ }
1394
+ });
1395
+ return out;
1396
+ }
1397
+
1398
+ // Lon/lat ring -> data coordinates: edges densified, parts beyond the horizon cut along it
1399
+ function projectRing(pj, ring) {
1400
+ if (ring.length < 3) return ring.map(([lo, la]) => pj.fwd(lo, la)).filter(Boolean);
1401
+ const pts = densifyGC(ring, 0.5), n = pts.length;
1402
+ const cs = pts.map(([lo, la]) => pj.cosc(lo, la)), vis = cs.map((c) => c >= pj.cmin);
1403
+ const start = vis.indexOf(true);
1404
+ if (start < 0) return [];
1405
+ if (vis.every(Boolean)) return pts.map(([lo, la]) => pj.fwd(lo, la));
1406
+ const rb = pj.rMax, on = (a) => [rb * Math.cos(a), rb * Math.sin(a)], out = [];
1407
+ let exitA = 0;
1408
+ for (let k = 0; k < n; k++) {
1409
+ const i = (start + k) % n, j = (i + 1) % n;
1410
+ if (vis[i]) out.push(pj.fwd(...pts[i]));
1411
+ if (vis[i] === vis[j]) continue;
1412
+ const t = (pj.cmin - cs[i]) / (cs[j] - cs[i]);
1413
+ const [rx, ry] = pj.raw(pts[i][0] + wrap180(pts[j][0] - pts[i][0]) * t, pts[i][1] + (pts[j][1] - pts[i][1]) * t);
1414
+ const a = Math.atan2(ry, rx);
1415
+ if (vis[i]) exitA = a;
1416
+ else {
1417
+ const d = mod(a - exitA + Math.PI, 2 * Math.PI) - Math.PI, m = Math.ceil(Math.abs(d) / (3 * RAD));
1418
+ for (let q = 1; q < m; q++) out.push(on(exitA + (d * q) / m));
1419
+ }
1420
+ out.push(on(a));
1421
+ }
1422
+ return out;
1423
+ }
1424
+
1425
+ const projectPolys = (pj, polys) => polys.map((P) => P.map((r) => projectRing(pj, r))).filter((P) => P[0].length)
1426
+ .map((P) => [P[0], ...P.slice(1).filter((h) => h.length >= 3)]);
1427
+
1428
+ // Inverse polar stereographic (psXY)
1429
+ function psLonLat(x, y, hemi) {
1430
+ const sgn = hemi === 'S' ? 1 : -1;
1431
+ return [mod(Math.atan2(x, sgn * y) * DEG, 360), sgn * (2 * Math.atan(Math.hypot(x, y) / (2 * MOON_RADIUS_KM * 1000)) - Math.PI / 2) * DEG];
1432
+ }
1433
+
1322
1434
  function renderMap(container, { hemi, region, items }) {
1323
1435
  container._resize?.disconnect();
1324
1436
  container._highlight = null;
1325
1437
  if (!items.length && !region) { container.replaceChildren(); return; }
1438
+ // hemi: the Auto choice from the region; generic: projected and clipped from lon/lat rings
1439
+ let autoHemi = hemi;
1440
+ const pj = mapProjection(mapPrefs.proj, hemi), generic = !pj.auto && pj.kind !== 'eqc';
1441
+ hemi = pj.kind === 'eqc' ? null : pj.hemi;
1326
1442
  const NS = 'http://www.w3.org/2000/svg';
1327
1443
  const svg = document.createElementNS(NS, 'svg');
1328
1444
  const reset = el('button', { class: 'tool', type: 'button', title: 'Reset view (double click)' }, 'Reset view');
@@ -1331,26 +1447,30 @@
1331
1447
  const cinfo = el('span', { class: 'info' });
1332
1448
  const pick = el('select', { 'aria-label': 'Basemap', title: 'Basemap under the footprints' });
1333
1449
  const cbox = el('input', { type: 'checkbox' });
1450
+ const ppick = el('select', { 'aria-label': 'Projection', title: 'Map projection' },
1451
+ ...Object.keys(MAP_PROJECTIONS).map((v) => el('option', { value: v, title: MAP_PROJECTIONS[v] }, projMenu(v))));
1452
+ ppick.value = MAP_PROJECTIONS[mapPrefs.proj] ? mapPrefs.proj : 'auto';
1334
1453
  const coord = el('div', { class: 'mapcoord' });
1335
1454
  const tools = el('span', { class: 'tools' }, ...Object.entries(MAP_TOOLS).map(([t, [label, title]]) =>
1336
1455
  el('button', { class: `tool${t === 'pan' ? ' on' : ''}`, type: 'button', 'data-tool': t, title }, label)));
1337
1456
  const bar = el('div', { class: 'panehead' },
1338
1457
  el('span', { class: 'title' }, 'Footprints'),
1339
- el('span', { class: 'info' }, hemi ? `Polar stereographic (${hemi === 'S' ? 'south' : 'north'}), R = ${MOON_RADIUS_KM} km`
1458
+ el('span', { class: 'info' }, generic ? `${MAP_PROJECTIONS[pj.id]}, R = ${MOON_RADIUS_KM} km`
1459
+ : hemi ? `Polar stereographic (${hemi === 'S' ? 'south' : 'north'}), R = ${MOON_RADIUS_KM} km`
1340
1460
  : 'Lon/lat (equirectangular)', ` | ${items.length} footprint(s) | wheel zoom, drag pan`),
1341
1461
  cinfo, spin,
1342
- el('span', { class: 'right' }, tools, pick, el('label', { class: 'toggle', title: 'Elevation contours (LOLA)' }, cbox, ' Contours'),
1462
+ el('span', { class: 'right' }, tools, ppick, pick, el('label', { class: 'toggle', title: 'Elevation contours (LOLA)' }, cbox, ' Contours'),
1343
1463
  layerToggle(container, 'fp', 'Footprints'), footprintStyle(container), layerToggle(container, 'region', 'Region'), reset));
1344
1464
  container.replaceChildren(el('div', { class: 'map' }, bar, el('div', { class: 'plotarea' }, canvas, svg, coord)));
1345
1465
  const clipId = `clip-${container.id}`;
1346
1466
  const M = MARGIN;
1347
1467
 
1348
1468
  // Lon/lat: center everything on the region (or the footprints) to avoid the seam
1349
- const regionGeom = region ? region.shape(hemi) : [];
1350
- let geoms = items.map((it) => it.geom);
1469
+ const regionGeom = region && !generic ? region.shape(hemi) : [];
1470
+ let geoms = generic ? [] : items.map((it) => (it.hemi === hemi ? it.geom : it.geomOf(hemi)));
1351
1471
  let kx = 1;
1352
1472
  let recenter = (g) => g;
1353
- if (!hemi) {
1473
+ if (pj.kind === 'eqc') {
1354
1474
  const mids = [...regionGeom, ...geoms.flat()].map((P) => { const b = bboxOf([P[0]]); return (b[0] + b[1]) / 2; });
1355
1475
  const c = region ? (region.region.span < 360 ? region.region.west + region.region.span / 2 : 180)
1356
1476
  : Math.atan2(sumOf(mids.map((m) => Math.sin(m * RAD))), sumOf(mids.map((m) => Math.cos(m * RAD)))) * DEG;
@@ -1361,16 +1481,19 @@
1361
1481
  regionGeom.splice(0, regionGeom.length, ...recenter(regionGeom));
1362
1482
  }
1363
1483
  // Data coordinates: x right, y down
1364
- const toXY = (g) => g.map((P) => P.map((r) => r.map(([x, y]) => (hemi ? [x, -y] : [x * kx, -y]))));
1365
- let reg = toXY(regionGeom);
1366
- const fps = geoms.map(toXY);
1484
+ if (pj.kind === 'eqc') pj.inv = ([x, y]) => [x / kx, -y];
1485
+ const toXY = (g) => (generic ? projectPolys(pj, g) : g.map((P) => P.map((r) => r.map(([x, y]) => (hemi ? [x, -y] : [x * kx, -y])))));
1486
+ const regionXY = (next) => toXY(generic ? next.shape('LL') : hemi ? next.shape(hemi) : recenter(next.shape(hemi)));
1487
+ let reg = generic ? (region ? regionXY(region) : []) : toXY(regionGeom);
1488
+ const fps = generic ? items.map((it) => toXY(it.geomOf('LL'))) : geoms.map(toXY);
1367
1489
  const all = [...reg, ...fps.flat()].flat();
1368
- let [x0, x1, y0, y1] = all.length ? bboxOf(all) : [-1, 1, -1, 1];
1369
- const minSpan = hemi ? 2000 : 0.05;
1490
+ const R = MOON_RADIUS_KM * 1000;
1491
+ let [x0, x1, y0, y1] = all.length ? bboxOf(all) : generic ? [-R, R, -R, R] : [-1, 1, -1, 1];
1492
+ const minSpan = pj.kind === 'eqc' ? 0.05 : 2000;
1370
1493
  if (x1 - x0 < minSpan) [x0, x1] = [(x0 + x1 - minSpan) / 2, (x0 + x1 + minSpan) / 2];
1371
1494
  if (y1 - y0 < minSpan) [y0, y1] = [(y0 + y1 - minSpan) / 2, (y0 + y1 + minSpan) / 2];
1372
1495
  const home = { cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, w: x1 - x0, h: y1 - y0 };
1373
- const under = mapUnderlay({ container, hemi, kx, canvas, spin, cinfo, pick, cbox, home, redraw: () => redraw() });
1496
+ const under = mapUnderlay({ container, pj, kx, canvas, spin, cinfo, pick, cbox, home, redraw: () => redraw() });
1374
1497
  // SW x SH: whole svg; W x H: plot area inside the axis margins
1375
1498
  let view = null, SW = 0, SH = 0, W = 0, H = 0, sel = -1, touched = false;
1376
1499
  const fit = () => {
@@ -1384,10 +1507,11 @@
1384
1507
  : 'M' + r.map(([x, y]) => `${sx(x).toFixed(1)} ${sy(y).toFixed(1)}`).join('L') + 'Z')).join('')).join('');
1385
1508
  const esc = (s) => String(s).replace(/[&<>"]/g, (ch) => `&#${ch.charCodeAt(0)};`);
1386
1509
  const sgn = hemi === 'S' ? 1 : -1;
1387
- const rho = (la) => 2 * MOON_RADIUS_KM * 1000 * Math.tan(Math.PI / 4 + sgn * (la * RAD) / 2);
1388
- const latOf = (r) => sgn * (2 * Math.atan(r / (2 * MOON_RADIUS_KM * 1000)) - Math.PI / 2) * DEG;
1389
- // Data coordinates (x right, y down) to [lon, lat]
1390
- const toLonLat = ([x, y]) => (hemi ? [mod(Math.atan2(x, -sgn * y) * DEG, 360), latOf(Math.hypot(x, y))] : [x / kx, -y]);
1510
+ // Polar aspects: radius of a parallel, and its inverse
1511
+ const rho = (la) => pj.rOfC(Math.PI / 2 + sgn * la * RAD);
1512
+ const latOf = (r) => sgn * (pj.cOfR(r) - Math.PI / 2) * DEG;
1513
+ // Data coordinates (x right, y down) to [lon, lat], null off the globe
1514
+ const toLonLat = (p) => pj.inv(p);
1391
1515
  coord.style.left = `${M.l + 6}px`;
1392
1516
  coord.style.bottom = `${M.b + 6}px`;
1393
1517
  let tool = 'pan', sketch = null;
@@ -1408,7 +1532,16 @@
1408
1532
  labels.push(`<text x="-6" y="${(y + 3.5).toFixed(1)}" text-anchor="end">${text}</text>`);
1409
1533
  };
1410
1534
  let xTitle, yTitle;
1411
- if (!hemi) {
1535
+ const inView = (x, y) => x >= 4 && x <= W - 30 && y >= 12 && y <= H - 4;
1536
+ const gl = (x, y, text) => { if (inView(x, y)) inner.push(`<text class="gl" x="${(x + 3).toFixed(1)}" y="${(y - 3).toFixed(1)}">${text}</text>`); };
1537
+ // Frame ticks in projected km (y up)
1538
+ const kmTicks = () => {
1539
+ const xs = step125((vx1 - vx0) / 1000, Math.max(2, W / 90)), ys = step125((vy1 - vy0) / 1000, Math.max(2, H / 50));
1540
+ for (let v = Math.ceil(vx0 / 1000 / xs) * xs; v <= vx1 / 1000; v += xs) xTick(sx(v * 1000), fmtDeg(v, xs));
1541
+ for (let v = Math.ceil(-vy1 / 1000 / ys) * ys; v <= -vy0 / 1000; v += ys) yTick(sy(-v * 1000), fmtDeg(v, ys));
1542
+ return ['x (km)', 'y (km)'];
1543
+ };
1544
+ if (pj.kind === 'eqc') {
1412
1545
  const lonStep = niceStep((vx1 - vx0) / kx), latStep = niceStep(vy1 - vy0);
1413
1546
  for (let lo = Math.ceil(vx0 / kx / lonStep) * lonStep; lo <= vx1 / kx; lo += lonStep) {
1414
1547
  const x = sx(lo * kx);
@@ -1422,14 +1555,14 @@
1422
1555
  yTick(y, fmtDeg(la, latStep));
1423
1556
  }
1424
1557
  [xTitle, yTitle] = ['Longitude (deg E)', 'Latitude (deg)'];
1425
- } else {
1426
- const inView = (x, y) => x >= 4 && x <= W - 30 && y >= 12 && y <= H - 4;
1558
+ } else if (hemi) {
1427
1559
  const cx = Math.min(Math.max(0, vx0), vx1), cy = Math.min(Math.max(0, vy0), vy1);
1428
1560
  const rMin = Math.hypot(cx, cy);
1429
- const rMax = Math.max(...[[vx0, vy0], [vx0, vy1], [vx1, vy0], [vx1, vy1]].map(([x, y]) => Math.hypot(x, y)));
1561
+ const rMax = Math.min(pj.rMax, Math.max(...[[vx0, vy0], [vx0, vy1], [vx1, vy0], [vx1, vy1]].map(([x, y]) => Math.hypot(x, y))));
1430
1562
  const [laIn, laOut] = [latOf(rMin), latOf(rMax)];
1431
1563
  const latStep = niceStep(Math.abs(laOut - laIn));
1432
- const poleward = (la) => (hemi === 'S' ? la <= 0 : la >= 0);
1564
+ // Auto: the polar hemisphere only; otherwise up to the horizon
1565
+ const poleward = (la) => 90 + sgn * la <= pj.gratCap + 1e-9;
1433
1566
  const angSpan = rMin > 0 ? Math.min(360, (2 * Math.hypot(vx1 - vx0, vy1 - vy0) / rMin) * DEG) : 360;
1434
1567
  const lonStep = Math.min(30, Math.max(0.5, niceStep(angSpan)));
1435
1568
  // Latitude labels between two meridians, towards the view centre
@@ -1441,22 +1574,56 @@
1441
1574
  for (const la of lats) {
1442
1575
  const r = rho(la) * view.k;
1443
1576
  lines.push(`M${(sx(0) - r).toFixed(1)} ${sy(0).toFixed(1)}a${r.toFixed(1)} ${r.toFixed(1)} 0 1 0 ${(2 * r).toFixed(1)} 0a${r.toFixed(1)} ${r.toFixed(1)} 0 1 0 ${(-2 * r).toFixed(1)} 0`);
1444
- const lx = sx(Math.cos(dirC) * rho(la)), ly = sy(Math.sin(dirC) * rho(la));
1445
- if (inView(lx, ly)) inner.push(`<text class="gl" x="${(lx + 3).toFixed(1)}" y="${(ly - 3).toFixed(1)}">${fmtDeg(la, latStep)}</text>`);
1577
+ gl(sx(Math.cos(dirC) * rho(la)), sy(Math.sin(dirC) * rho(la)), fmtDeg(la, latStep));
1446
1578
  }
1447
1579
  const rLab = Math.max(Math.hypot(view.cx, view.cy), 0.3 * Math.min(W, H) / view.k);
1448
- for (let lo = 0; lo < 360; lo += lonStep) {
1580
+ for (let lo = 0; lo < 360 && rMin < rMax; lo += lonStep) {
1449
1581
  // Data coords (y down): S x = rho sin, y = -rho cos; N y = rho cos
1450
1582
  const ux = Math.sin(lo * RAD), uy = -sgn * Math.cos(lo * RAD);
1451
1583
  lines.push(`M${sx(ux * rMin).toFixed(1)} ${sy(uy * rMin).toFixed(1)}L${sx(ux * rMax).toFixed(1)} ${sy(uy * rMax).toFixed(1)}`);
1452
- const lx = sx(ux * rLab), ly = sy(uy * rLab);
1453
- if (inView(lx, ly)) inner.push(`<text class="gl" x="${(lx + 3).toFixed(1)}" y="${(ly - 3).toFixed(1)}">${fmtDeg(lo, lonStep)}E</text>`);
1584
+ if (rLab <= rMax) gl(sx(ux * rLab), sy(uy * rLab), `${fmtDeg(lo, lonStep)}E`);
1454
1585
  }
1455
- // Frame ticks in polar stereographic km (y up)
1456
- const xs = step125((vx1 - vx0) / 1000, Math.max(2, W / 90)), ys = step125((vy1 - vy0) / 1000, Math.max(2, H / 50));
1457
- for (let v = Math.ceil(vx0 / 1000 / xs) * xs; v <= vx1 / 1000; v += xs) xTick(sx(v * 1000), fmtDeg(v, xs));
1458
- for (let v = Math.ceil(-vy1 / 1000 / ys) * ys; v <= -vy0 / 1000; v += ys) yTick(sy(-v * 1000), fmtDeg(v, ys));
1459
- [xTitle, yTitle] = ['x (km)', 'y (km)'];
1586
+ [xTitle, yTitle] = kmTicks();
1587
+ } else {
1588
+ // Equatorial aspects: meridians and parallels as projected polylines, labelled at the
1589
+ // bottom (meridians) and left (parallels) of the view
1590
+ const lls = [];
1591
+ for (let j = 0; j <= 16; j++) {
1592
+ for (let i = 0; i <= 16; i++) {
1593
+ const ll = pj.inv([vx0 + ((vx1 - vx0) * i) / 16, vy0 + ((vy1 - vy0) * j) / 16]);
1594
+ if (ll) lls.push(ll);
1595
+ }
1596
+ }
1597
+ const poleIn = (la) => { const p = pj.fwd(0, la); return p && p[0] >= vx0 && p[0] <= vx1 && p[1] >= vy0 && p[1] <= vy1; };
1598
+ if (lls.length) {
1599
+ const laMin = poleIn(-90) ? -90 : minOf(lls.map((p) => p[1])), laMax = poleIn(90) ? 90 : maxOf(lls.map((p) => p[1]));
1600
+ let [lw, lspan] = poleIn(-90) || poleIn(90) ? [0, 360] : coveringArc(lls.map(([lo]) => [lo, 0]));
1601
+ const latStep = Math.min(30, niceStep(Math.max(laMax - laMin, 1e-4))), lonStep = Math.min(30, niceStep(Math.max(lspan, 1e-4)));
1602
+ if (lspan < 360) [lw, lspan] = [lw - lonStep, Math.min(360, lspan + 2 * lonStep)];
1603
+ const [la0, la1] = [Math.max(-90, laMin - latStep), Math.min(90, laMax + latStep)];
1604
+ const line = (pts, text, pickLabel) => {
1605
+ let d = '', pen = false, best = null;
1606
+ for (const [lo, la] of pts) {
1607
+ const p = pj.fwd(lo, la);
1608
+ if (!p) { pen = false; continue; }
1609
+ const x = sx(p[0]), y = sy(p[1]);
1610
+ d += `${pen ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`;
1611
+ pen = true;
1612
+ if (inView(x, y) && Math.abs(la) < 89 && (!best || pickLabel([x, y], best))) best = [x, y];
1613
+ }
1614
+ lines.push(d);
1615
+ if (best) gl(best[0], best[1], text);
1616
+ };
1617
+ const N = 120;
1618
+ for (let lo = Math.ceil(lw / lonStep) * lonStep; lo <= lw + lspan && lo < lw + 360; lo += lonStep) {
1619
+ line(Array.from({ length: N + 1 }, (_, i) => [lo, la0 + ((la1 - la0) * i) / N]), `${fmtDeg(mod(lo, 360), lonStep)}E`, (p, b) => p[1] > b[1]);
1620
+ }
1621
+ for (let la = Math.ceil(laMin / latStep) * latStep; la <= laMax; la += latStep) {
1622
+ if (Math.abs(la) >= 90) continue;
1623
+ line(Array.from({ length: 2 * N + 1 }, (_, i) => [lw + (lspan * i) / (2 * N), la]), fmtDeg(la, latStep), (p, b) => p[0] < b[0]);
1624
+ }
1625
+ }
1626
+ [xTitle, yTitle] = kmTicks();
1460
1627
  }
1461
1628
  labels.push(`<text class="axis" x="${(W / 2).toFixed(1)}" y="${H + 29}" text-anchor="middle">${xTitle}</text>`,
1462
1629
  `<text class="axis" transform="translate(${-M.l + 12} ${(H / 2).toFixed(1)}) rotate(-90)" text-anchor="middle">${yTitle}</text>`);
@@ -1472,7 +1639,9 @@
1472
1639
  const ds = fps.map((g) => pathD(g));
1473
1640
  svg.innerHTML = `<defs><clipPath id="${clipId}"><rect width="${W}" height="${H}"/></clipPath></defs>`
1474
1641
  + `<g transform="translate(${M.l} ${M.t})"><rect class="plot" width="${W}" height="${H}"/>`
1475
- + `<g clip-path="url(#${clipId})">${a.grid}<g class="cslot"/>`
1642
+ + `<g clip-path="url(#${clipId})">${a.grid}`
1643
+ + (pj.kind === 'ortho' ? `<circle class="limb" cx="${sx(0).toFixed(1)}" cy="${sy(0).toFixed(1)}" r="${(pj.rMax * view.k).toFixed(1)}"/>` : '')
1644
+ + '<g class="cslot"/>'
1476
1645
  + `<g class="fpfill">${ds.map((d) => `<path d="${d}"/>`).join('')}</g>`
1477
1646
  + order.map((i) => `<path class="fp${i === sel ? ' sel' : ''}" data-i="${i}" d="${ds[i]}"><title>${esc(items[i].title)}</title></path>`).join('')
1478
1647
  + (reg.length ? `<path class="region" d="${pathD(reg)}"/>` : '')
@@ -1506,6 +1675,11 @@
1506
1675
  const [[ax, ay], [bx, by]] = [sketch.a, sketch.b];
1507
1676
  return `<path class="sketch" d="M${P([ax, ay])}L${P([bx, ay])}L${P([bx, by])}L${P([ax, by])}Z"/>`;
1508
1677
  }
1678
+ if (sketch.tool === 'circle' && generic) {
1679
+ const [a, b] = [toLonLat(sketch.a), toLonLat(sketch.b)];
1680
+ const ring = a && b ? projectRing(pj, circleRing(a[1], a[0], greatCircleKm(a[1], a[0], b[1], b[0]))) : [];
1681
+ return ring.length ? `<path class="sketch" d="M${ring.map(P).join('L')}Z"/>` : '';
1682
+ }
1509
1683
  if (sketch.tool === 'circle') {
1510
1684
  const r = screenDist(sketch.a, sketch.b).toFixed(1);
1511
1685
  return `<circle class="sketch" cx="${sx(sketch.a[0]).toFixed(1)}" cy="${sy(sketch.a[1]).toFixed(1)}" r="${r}"/>`;
@@ -1522,7 +1696,7 @@
1522
1696
  };
1523
1697
  for (const b of tools.children) b.addEventListener('click', () => setTool(b.dataset.tool));
1524
1698
  // Densified ring in data coordinates -> lon/lat, so edges follow the map's straight lines
1525
- const ringLonLat = (pts, n = hemi ? 16 : 1) => pts.flatMap((p, i) => {
1699
+ const ringLonLat = (pts, n = pj.kind === 'eqc' ? 1 : 16) => pts.flatMap((p, i) => {
1526
1700
  const q = pts[(i + 1) % pts.length];
1527
1701
  return Array.from({ length: n }, (_, j) => toLonLat([p[0] + ((q[0] - p[0]) * j) / n, p[1] + ((q[1] - p[1]) * j) / n]));
1528
1702
  });
@@ -1531,16 +1705,19 @@
1531
1705
  setTool('pan');
1532
1706
  if (!sk) return;
1533
1707
  if (sk.tool === 'circle') {
1534
- const [lon, lat] = toLonLat(sk.a), [lon2, lat2] = toLonLat(sk.b);
1708
+ const [a, b] = [toLonLat(sk.a), toLonLat(sk.b)];
1709
+ if (!a || !b) return;
1710
+ const [[lon, lat], [lon2, lat2]] = [a, b];
1535
1711
  setFormRegion('point', { lat, lon, radius: greatCircleKm(lat, lon, lat2, lon2) });
1536
- } else if (sk.tool === 'box' && !hemi) {
1712
+ } else if (sk.tool === 'box' && pj.kind === 'eqc') {
1537
1713
  const [[w, n], [e, s]] = [toLonLat([Math.min(sk.a[0], sk.b[0]), Math.min(sk.a[1], sk.b[1])]),
1538
1714
  toLonLat([Math.max(sk.a[0], sk.b[0]), Math.max(sk.a[1], sk.b[1])])];
1539
1715
  const full = e - w >= 360;
1540
1716
  setFormRegion('box', { west: full ? 0 : mod(w, 360), east: full ? 360 : mod(e, 360), south: s, north: n });
1541
1717
  } else {
1542
1718
  const pts = sk.tool === 'box' ? [sk.a, [sk.b[0], sk.a[1]], sk.b, [sk.a[0], sk.b[1]]] : sk.pts;
1543
- const ring = ringLonLat(pts).map(([lon, lat]) => [mod(lon, 360), lat]);
1719
+ const ring = ringLonLat(pts).filter(Boolean).map(([lon, lat]) => [mod(lon, 360), lat]);
1720
+ if (ring.length < 3) return;
1544
1721
  setFormRegion('aoi', { aoi: `POLYGON((${[...ring, ring[0]].map(([lon, lat]) => `${lon.toFixed(5)} ${lat.toFixed(5)}`).join(', ')}))` });
1545
1722
  }
1546
1723
  }
@@ -1550,9 +1727,10 @@
1550
1727
  if (mx < 0 || my < 0 || mx > W || my > H) { $('cursor').textContent = coord.textContent = ''; return; }
1551
1728
  const x = view.cx + (mx - W / 2) / view.k, y = -(view.cy + (my - H / 2) / view.k);
1552
1729
  let lat, lon, xy = '';
1553
- if (hemi) {
1554
- lat = latOf(Math.hypot(x, y));
1555
- lon = mod(Math.atan2(x, sgn * y) * DEG, 360);
1730
+ if (pj.kind !== 'eqc') {
1731
+ const ll = pj.inv([x, -y]);
1732
+ if (!ll) { $('cursor').textContent = coord.textContent = ''; return; }
1733
+ [lon, lat] = ll;
1556
1734
  xy = ` x ${(x / 1000).toFixed(2)} km y ${(y / 1000).toFixed(2)} km`;
1557
1735
  } else {
1558
1736
  [lat, lon] = [y, mod(x / kx, 360)];
@@ -1611,12 +1789,20 @@
1611
1789
  });
1612
1790
  reset.addEventListener('click', home0);
1613
1791
  container._highlight = (row) => { sel = items.findIndex((it) => it.row === row); draw(); };
1792
+ ppick.addEventListener('change', () => {
1793
+ mapPrefs.proj = ppick.value;
1794
+ saveMapPrefs();
1795
+ const row = items[sel]?.row;
1796
+ renderMap(container, { hemi: autoHemi, region, items });
1797
+ if (row) container._highlight?.(row);
1798
+ });
1614
1799
  // A new search region from the form or the map, keeping the view and the results
1615
1800
  container._setRegion = (next) => {
1616
1801
  const h = mapHemi(next.region, []);
1617
- if (h !== hemi) { renderMap(container, { hemi: h, region: next, items }); return; }
1802
+ if (mapProjection(mapPrefs.proj, h).id !== pj.id) { renderMap(container, { hemi: h, region: next, items }); return; }
1618
1803
  region = next;
1619
- reg = toXY(hemi ? next.shape(hemi) : recenter(next.shape(hemi)));
1804
+ autoHemi = h;
1805
+ reg = regionXY(next);
1620
1806
  redraw();
1621
1807
  };
1622
1808
  let lastW = 0, lastH = 0;
@@ -1647,7 +1833,7 @@
1647
1833
  const items = [];
1648
1834
  for (const row of rows) {
1649
1835
  const geom = rowFootprint(row, wktCol, hemi);
1650
- if (geom) items.push({ geom, row, title: title(row), onclick: () => onclick(row) });
1836
+ if (geom) items.push({ geom, hemi, geomOf: (h) => rowFootprint(row, wktCol, h), row, title: title(row), onclick: () => onclick(row) });
1651
1837
  }
1652
1838
  return items;
1653
1839
  }
@@ -2838,9 +3024,10 @@
2838
3024
  N: ['lola_dem_north'],
2839
3025
  };
2840
3026
  const BM_STRETCH = { min: 0, max: 255, gamma: 1 };
3027
+ const POLAR_OVERVIEW_M = 609600; // half width of the bundled polar overviews
2841
3028
  // Map preferences, remembered across searches and (when storage works) sessions
2842
3029
  const MAP_PREFS_KEY = 'lunar-toolkit-map';
2843
- const mapPrefs = { basemap: 'wac', contours: false, fpLine: 'dotted', fpFill: 'light', fpColor: 'blue' };
3030
+ const mapPrefs = { proj: 'auto', basemap: 'wac', contours: false, fpLine: 'dotted', fpFill: 'light', fpColor: 'blue' };
2844
3031
  try { Object.assign(mapPrefs, JSON.parse(localStorage.getItem(MAP_PREFS_KEY) ?? '{}')); } catch { /* storage blocked */ }
2845
3032
  const saveMapPrefs = () => { try { localStorage.setItem(MAP_PREFS_KEY, JSON.stringify(mapPrefs)); } catch { /* storage blocked */ } };
2846
3033
  const bmOpened = new Map(); // ref -> { promise, src, pending, failed }
@@ -2886,12 +3073,15 @@
2886
3073
  tileAdd(key, { canvas, w, h, bytes: 0 }, w * h * 4);
2887
3074
  }
2888
3075
 
2889
- // Marching squares over grid parts { z, w, h, x(i), y(j) }: SVG paths (minor, major every 5th)
2890
- function contourPaths(parts, noData, step, X, Y) {
3076
+ // Marching squares over grid parts { z, w, h, x(i), y(j) }: SVG paths (minor, major every 5th);
3077
+ // P(x, y) gives the screen point, or null to drop the segment
3078
+ function contourPaths(parts, noData, step, P) {
2891
3079
  const out = [[], []];
2892
3080
  for (const { z, w, h, x, y } of parts) {
2893
- const seg = (k, [i0, j0], [i1, j1]) => out[k % 5 === 0 ? 1 : 0].push(
2894
- `M${X(x(i0)).toFixed(1)} ${Y(y(j0)).toFixed(1)}L${X(x(i1)).toFixed(1)} ${Y(y(j1)).toFixed(1)}`);
3081
+ const seg = (k, [i0, j0], [i1, j1]) => {
3082
+ const a = P(x(i0), y(j0)), b = a && P(x(i1), y(j1));
3083
+ if (b) out[k % 5 === 0 ? 1 : 0].push(`M${a[0].toFixed(1)} ${a[1].toFixed(1)}L${b[0].toFixed(1)} ${b[1].toFixed(1)}`);
3084
+ };
2895
3085
  for (let j = 0; j < h - 1; j++) {
2896
3086
  for (let i = 0; i < w - 1; i++) {
2897
3087
  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];
@@ -2912,10 +3102,15 @@
2912
3102
  return out.map((a) => a.join(''));
2913
3103
  }
2914
3104
 
2915
- // The basemap canvas and the contour layer of one footprint map
2916
- function mapUnderlay({ container, hemi, kx, canvas, spin, cinfo, pick, cbox, home, redraw }) {
3105
+ // Re-rendering the map aborts the old map's reads; geotiff.js leaves some of those rejections unobserved
3106
+ window.addEventListener('unhandledrejection', (e) => { if (e.reason?.name === 'AbortError') e.preventDefault(); });
3107
+
3108
+ // The basemap canvas and the contour layer of one footprint map. When the map projection is a
3109
+ // raster's own (pj.raster) tiles are placed affinely, else reprojected pixel by pixel.
3110
+ function mapUnderlay({ container, pj, kx, canvas, spin, cinfo, pick, cbox, home, redraw }) {
2917
3111
  bmNotify?.abort?.();
2918
- const proj = hemi ?? 'eqc', sc = hemi ? 1 : kx, NS = 'http://www.w3.org/2000/svg';
3112
+ const native = pj.raster, hemi = native === 'eqc' ? null : native;
3113
+ const proj = hemi ?? 'eqc', sc = native === 'eqc' ? kx : 1, NS = 'http://www.w3.org/2000/svg';
2919
3114
  const ctl = new AbortController();
2920
3115
  const st = { wanted: [], inflight: new Set(), failed: new Map(), timer: 0, ctimer: 0, ckey: '', gen: 0, cbusy: false, contour: null };
2921
3116
  const notify = () => { if (container.isConnected) redraw(); };
@@ -2928,12 +3123,16 @@
2928
3123
  contours.append(p);
2929
3124
  return p;
2930
3125
  });
2931
- // Raster extent in map data coordinates (x right, y down)
2932
- const geom = (s) => { const [x0, y0, x1, y1] = s.box; return { X0: x0 * sc, X1: x1 * sc, Y0: -y1, Y1: -y0 }; };
2933
- // x offsets of the wrapped lon/lat copies of g overlapping [vx0, vx1]
2934
- const copies = (g, vx0, vx1) => {
2935
- if (hemi) return [0];
2936
- const p = 360 * sc, n0 = Math.ceil((vx0 - g.X1) / p), n1 = Math.floor((vx1 - g.X0) / p);
3126
+ // A chosen polar stereographic view reaching past the polar overviews is reprojected, so the
3127
+ // global rasters fill it (Auto keeps the polar rasters only)
3128
+ const affine = (view, W, H) => native && (pj.auto || !hemi
3129
+ || Math.max(Math.abs(view.cx) + W / 2 / view.k, Math.abs(view.cy) + H / 2 / view.k) <= POLAR_OVERVIEW_M);
3130
+ // Raster extent in map (or, reprojecting, the raster's own) data coordinates (x right, y down)
3131
+ const geom = (s, k = sc) => { const [x0, y0, x1, y1] = s.box; return { X0: x0 * k, X1: x1 * k, Y0: -y1, Y1: -y0 }; };
3132
+ // x offsets of the wrapped lon/lat copies of g overlapping [vx0, vx1] (period p, 0 for polar)
3133
+ const copies = (g, vx0, vx1, p = hemi ? 0 : 360 * sc) => {
3134
+ if (!p) return [0];
3135
+ const n0 = Math.ceil((vx0 - g.X1) / p), n1 = Math.floor((vx1 - g.X0) / p);
2937
3136
  return n1 - n0 > 8 ? [] : Array.from({ length: n1 - n0 + 1 }, (_, i) => (n0 + i) * p);
2938
3137
  };
2939
3138
  const choices = Object.keys(BASEMAPS[proj]).filter((c) => !['shade5', 'nac'].includes(c)
@@ -2979,6 +3178,9 @@
2979
3178
  const r = dpr();
2980
3179
  Object.assign(canvas.style, { left: `${MARGIN.l}px`, top: `${MARGIN.t}px`, width: `${W}px`, height: `${H}px` });
2981
3180
  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)];
3181
+ if (!affine(view, W, H)) { queue(reproject(view, W, H, r), view, W, H); return; }
3182
+ clearTimeout(rp.timer);
3183
+ rp.vkey = '';
2982
3184
  const ctx = canvas.getContext('2d');
2983
3185
  ctx.setTransform(r, 0, 0, r, 0, 0);
2984
3186
  ctx.clearRect(0, 0, W, H);
@@ -3032,6 +3234,10 @@
3032
3234
  }
3033
3235
  }
3034
3236
  }
3237
+ queue(want, view, W, H);
3238
+ }
3239
+
3240
+ function queue(want, view, W, H) {
3035
3241
  st.wanted = want.sort((a, b) => a.d - b.d);
3036
3242
  clearTimeout(st.timer);
3037
3243
  st.timer = setTimeout(pump, 80);
@@ -3039,6 +3245,116 @@
3039
3245
  status();
3040
3246
  }
3041
3247
 
3248
+ // Reprojection: inverse-project a grid of screen pixels (every 3rd device pixel while the view
3249
+ // moves, every one once it rests) and sample the global raster, or the polar one poleward of
3250
+ // 72 deg, from the tiles of the level that suits the zoom, else the bundled overview
3251
+ const rp = { vkey: '', gkey: '', lon: null, lat: null, want: [], timer: 0, out: document.createElement('canvas') };
3252
+ const overviewPx = (b) => {
3253
+ if (!b.px) {
3254
+ const c = Object.assign(document.createElement('canvas'), { width: b.w0, height: b.h0 });
3255
+ const cx = c.getContext('2d', { willReadFrequently: true });
3256
+ cx.drawImage(b.pic, 0, 0);
3257
+ const d = cx.getImageData(0, 0, b.w0, b.h0).data;
3258
+ b.px = new Uint8Array(b.w0 * b.h0);
3259
+ for (let i = 0; i < b.px.length; i++) b.px[i] = d[4 * i];
3260
+ c.width = 0;
3261
+ }
3262
+ return b.px;
3263
+ };
3264
+ const tilePx = (t) => {
3265
+ if (!t.px) {
3266
+ t.px = new Uint32Array(t.canvas.getContext('2d', { willReadFrequently: true }).getImageData(0, 0, t.w, t.h).data.buffer);
3267
+ tileAdd(null, t, t.w * t.h * 4);
3268
+ }
3269
+ return t.px;
3270
+ };
3271
+
3272
+ function reproject(view, W, H, r) {
3273
+ const vkey = `${view.cx}|${view.cy}|${view.k}|${W}|${H}|${choice()}`;
3274
+ clearTimeout(rp.timer);
3275
+ if (vkey !== rp.vkey) {
3276
+ rp.vkey = vkey;
3277
+ render(view, W, H, r, 3);
3278
+ }
3279
+ rp.timer = setTimeout(() => { if (container.isConnected) queue(render(view, W, H, r, 1), view, W, H); }, 200);
3280
+ return rp.want;
3281
+ }
3282
+
3283
+ function render(view, W, H, r, f) {
3284
+ const ctx = canvas.getContext('2d');
3285
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
3286
+ const ch = choice();
3287
+ if (ch === 'none') { ctx.clearRect(0, 0, canvas.width, canvas.height); return (rp.want = []); }
3288
+ const gw = Math.ceil((W * r) / f), gh = Math.ceil((H * r) / f), n = gw * gh;
3289
+ const gkey = `${view.cx}|${view.cy}|${view.k}|${gw}|${gh}`;
3290
+ if (gkey !== rp.gkey) {
3291
+ rp.gkey = gkey;
3292
+ [rp.lon, rp.lat] = [new Float64Array(n), new Float64Array(n)];
3293
+ for (let j = 0, k = 0; j < gh; j++) {
3294
+ const y = view.cy + (((j + 0.5) * f) / r - H / 2) / view.k;
3295
+ for (let i = 0; i < gw; i++, k++) {
3296
+ const ll = pj.inv([view.cx + (((i + 0.5) * f) / r - W / 2) / view.k, y]);
3297
+ [rp.lon[k], rp.lat[k]] = ll ?? [NaN, NaN];
3298
+ }
3299
+ }
3300
+ }
3301
+ const mppScreen = 1 / (view.k * r), Rm = MOON_RADIUS_KM * 1000;
3302
+ // Per family: overview pixels, and the streamed level when the overview is magnified
3303
+ const sampler = (fam) => {
3304
+ const stack = BASEMAPS[fam][ch], [b, c] = stack ? stack.map((ref) => bmLoad(ref).src) : [];
3305
+ if (!b) return null;
3306
+ const m = (box, w) => ((box[2] - box[0]) / w) * (fam === 'eqc' ? RAD * Rm : 1);
3307
+ const S = { fam, b, px: overviewPx(b), lv: null };
3308
+ if (c) {
3309
+ let li = 0;
3310
+ c.levels.forEach((L, i) => { if (m(c.box, L.w) <= mppScreen * 1.0001) li = i; });
3311
+ const L = c.levels[li];
3312
+ if (m(c.box, L.w) * 1.5 < m(b.box, b.w0)) S.lv = { c, li, L, ntx: Math.ceil(L.w / L.tw), tiles: new Map() };
3313
+ }
3314
+ return S;
3315
+ };
3316
+ const fams = { eqc: sampler('eqc'), S: sampler('S'), N: sampler('N') };
3317
+ const img = new ImageData(gw, gh), out = new Uint32Array(img.data.buffer), want = [];
3318
+ let lastId = -1, lastT = null, lastLv = null;
3319
+ for (let k = 0; k < n; k++) {
3320
+ const lo = rp.lon[k], la = rp.lat[k];
3321
+ if (lo !== lo) continue;
3322
+ const F = (la <= -72 && fams.S) || (la >= 72 && fams.N) || fams.eqc;
3323
+ if (!F) continue;
3324
+ let X = lo, Y = la;
3325
+ if (F.fam !== 'eqc') [X, Y] = psXY(la, lo, F.fam);
3326
+ const lv = F.lv;
3327
+ if (lv) {
3328
+ const bx = lv.c.box, u = (F.fam === 'eqc' ? mod(X - bx[0], 360) : X - bx[0]) / (bx[2] - bx[0]), v = (bx[3] - Y) / (bx[3] - bx[1]);
3329
+ if (u >= 0 && u < 1 && v >= 0 && v < 1) {
3330
+ const L = lv.L, px = Math.floor(u * L.w), py = Math.floor(v * L.h), tx = Math.floor(px / L.tw), ty = Math.floor(py / L.th);
3331
+ const id = ty * lv.ntx + tx;
3332
+ if (id !== lastId || lv !== lastLv) {
3333
+ [lastId, lastLv] = [id, lv];
3334
+ lastT = lv.tiles.get(id);
3335
+ if (lastT === undefined) {
3336
+ const key = `bm|${lv.c.key}|${lv.li}|${tx}|${ty}`, t = tileGet(key);
3337
+ lastT = t ? { px: tilePx(t), w: t.w } : null;
3338
+ lv.tiles.set(id, lastT);
3339
+ if (!t) want.push({ key, src: lv.c, li: lv.li, tx, ty, d: Math.hypot((k % gw) - gw / 2, Math.floor(k / gw) - gh / 2) });
3340
+ }
3341
+ }
3342
+ const val = lastT ? lastT.px[(py - ty * L.th) * lastT.w + (px - tx * L.tw)] : 0;
3343
+ if (val >>> 24) { out[k] = val; continue; }
3344
+ }
3345
+ }
3346
+ const bx = F.b.box, u = (F.fam === 'eqc' ? mod(X - bx[0], 360) : X - bx[0]) / (bx[2] - bx[0]), v = (bx[3] - Y) / (bx[3] - bx[1]);
3347
+ if (!(u >= 0 && u < 1 && v >= 0 && v < 1)) continue;
3348
+ out[k] = 0xff000000 | (F.px[Math.floor(v * F.b.h0) * F.b.w0 + Math.floor(u * F.b.w0)] * 0x010101);
3349
+ }
3350
+ if (rp.out.width !== gw || rp.out.height !== gh) [rp.out.width, rp.out.height] = [gw, gh];
3351
+ rp.out.getContext('2d').putImageData(img, 0, 0);
3352
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
3353
+ ctx.imageSmoothingEnabled = true;
3354
+ ctx.drawImage(rp.out, 0, 0, gw * f, gh * f);
3355
+ return (rp.want = want);
3356
+ }
3357
+
3042
3358
  function placeContours(view, W, H) {
3043
3359
  const c = st.contour;
3044
3360
  if (c) {
@@ -3053,23 +3369,53 @@
3053
3369
  st.ctimer = setTimeout(() => computeContours({ ...view }, W, H), 300);
3054
3370
  }
3055
3371
 
3056
- // DEM samples about 3 screen px apart over the view, from the coarsest adequate level
3057
- async function demGrid(src, view, W, H, want) {
3058
- const g = geom(src);
3372
+ // DEM samples about 3 screen px apart over the window, from the coarsest adequate level
3373
+ // (at most about 2 M samples)
3374
+ async function demGrid(src, g, [vx0, vx1, vy0, vy1], want, period, polar) {
3059
3375
  let li = 0;
3060
3376
  src.levels.forEach((L, i) => { if (((g.Y1 - g.Y0) / L.h) <= want) li = i; });
3377
+ const cells = (L) => (((vx1 - vx0) * L.w) / (g.X1 - g.X0)) * Math.min(L.h, ((vy1 - vy0) * L.h) / (g.Y1 - g.Y0));
3378
+ while (li < src.levels.length - 1 && cells(src.levels[li]) > 2e6) li++;
3061
3379
  const L = src.levels[li], ux = (g.X1 - g.X0) / L.w, uy = (g.Y1 - g.Y0) / L.h;
3062
- const vx0 = view.cx - W / 2 / view.k, vx1 = view.cx + W / 2 / view.k;
3063
- const vy0 = view.cy - H / 2 / view.k, vy1 = view.cy + H / 2 / view.k;
3064
3380
  const r0 = Math.max(0, Math.floor((vy0 - g.Y0) / uy) - 1), r1 = Math.min(L.h, Math.ceil((vy1 - g.Y0) / uy) + 1);
3065
3381
  const parts = [];
3066
- for (const dx of copies(g, vx0, vx1)) {
3382
+ for (const dx of copies(g, vx0, vx1, period)) {
3067
3383
  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);
3068
3384
  if (c1 - c0 < 2 || r1 - r0 < 2) continue;
3069
3385
  const z = (await readWindow({ ...src, samples: [0] }, L, [c0, r0, c1, r1], ctl.signal))[0];
3070
3386
  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 });
3071
3387
  }
3072
- return { parts, mpp: hemi ? uy : uy * RAD * MOON_RADIUS_KM * 1000 };
3388
+ return { parts, mpp: polar ? uy : uy * RAD * MOON_RADIUS_KM * 1000 };
3389
+ }
3390
+
3391
+ // Reprojected map: the DEM family for the view (polar when it all lies poleward of 72 deg)
3392
+ // and the view's window in that raster's data coordinates, from a grid of view points
3393
+ function viewWindow(view, W, H) {
3394
+ const vx0 = view.cx - W / 2 / view.k, vy0 = view.cy - H / 2 / view.k, pts = [];
3395
+ for (let j = 0; j <= 16; j++) {
3396
+ for (let i = 0; i <= 16; i++) {
3397
+ const ll = pj.inv([vx0 + (W / view.k) * (i / 16), vy0 + (H / view.k) * (j / 16)]);
3398
+ if (ll) pts.push(ll);
3399
+ }
3400
+ }
3401
+ if (!pts.length) return null;
3402
+ const poleIn = (la) => {
3403
+ const p = pj.fwd(0, la);
3404
+ return p && Math.abs(p[0] - view.cx) <= W / 2 / view.k && Math.abs(p[1] - view.cy) <= H / 2 / view.k;
3405
+ };
3406
+ const lats = pts.map((p) => p[1]);
3407
+ const fam = lats.every((la) => la < -72) ? 'S' : lats.every((la) => la > 72) ? 'N' : 'eqc';
3408
+ if (fam !== 'eqc') {
3409
+ const xy = pts.map(([lo, la]) => psXY(la, lo, fam));
3410
+ if (poleIn(fam === 'S' ? -90 : 90)) xy.push([0, 0]);
3411
+ const [x0, x1, y0, y1] = bboxOf([xy]), pad = 0.03 * Math.max(x1 - x0, y1 - y0) + 1;
3412
+ return { fam, win: [x0 - pad, x1 + pad, -y1 - pad, -y0 + pad] };
3413
+ }
3414
+ let [w, span] = poleIn(-90) || poleIn(90) ? [-180, 360] : coveringArc(pts.map(([lo]) => [lo, 0]));
3415
+ if (span > 300) [w, span] = [-180, 360];
3416
+ const s0 = poleIn(-90) ? -90 : minOf(lats), n0 = poleIn(90) ? 90 : maxOf(lats);
3417
+ const px = 0.03 * span + 0.01, py = 0.03 * (n0 - s0) + 0.01;
3418
+ return { fam, win: span >= 360 ? [-180, 180, -n0 - py, -s0 + py] : [w - px, w + span + px, -n0 - py, -s0 + py] };
3073
3419
  }
3074
3420
 
3075
3421
  async function computeContours(view, W, H) {
@@ -3077,22 +3423,25 @@
3077
3423
  st.cbusy = true;
3078
3424
  status();
3079
3425
  try {
3080
- const want = 3 / view.k;
3081
- const vx0 = view.cx - W / 2 / view.k, vx1 = view.cx + W / 2 / view.k;
3082
- const vy0 = view.cy - H / 2 / view.k, vy1 = view.cy + H / 2 / view.k;
3083
- const refs = CONTOUR_DEMS[proj].filter((ref) => ref !== PGDA_80S || relayable(ref));
3084
- const bundled = await bmLoad(refs.at(-1)).promise;
3085
- const res = bundled ? (geom(bundled).Y1 - geom(bundled).Y0) / bundled.h0 : Infinity;
3426
+ const nat = affine(view, W, H);
3427
+ const vw = nat ? { fam: proj, k: sc, win: [view.cx - W / 2 / view.k, view.cx + W / 2 / view.k, view.cy - H / 2 / view.k, view.cy + H / 2 / view.k] }
3428
+ : { k: 1, ...viewWindow(view, W, H) };
3429
+ const polar = vw.fam !== 'eqc', gm = (src) => geom(src, vw.k);
3430
+ const want = 3 / view.k / (nat || polar ? 1 : RAD * MOON_RADIUS_KM * 1000);
3431
+ const [vx0, vx1, vy0, vy1] = vw.win ?? [];
3432
+ const refs = vw.fam ? CONTOUR_DEMS[vw.fam].filter((ref) => ref !== PGDA_80S || relayable(ref)) : [];
3433
+ const bundled = refs.length ? await bmLoad(refs.at(-1)).promise : null;
3434
+ const res = bundled ? (gm(bundled).Y1 - gm(bundled).Y0) / bundled.h0 : Infinity;
3086
3435
  let grid = null, name = '';
3087
3436
  for (const ref of refs) {
3088
3437
  const last = ref === refs.at(-1);
3089
3438
  if (last ? !bundled : res <= want) continue;
3090
3439
  const src = last ? bundled : await bmLoad(ref).promise;
3091
3440
  if (gen !== st.gen) return;
3092
- const g = src && geom(src);
3093
- if (!src || (!last && hemi && !(vx0 >= g.X0 && vx1 <= g.X1 && vy0 >= g.Y0 && vy1 <= g.Y1))) continue;
3441
+ const g = src && gm(src);
3442
+ if (!src || (!last && polar && !(vx0 >= g.X0 && vx1 <= g.X1 && vy0 >= g.Y0 && vy1 <= g.Y1))) continue;
3094
3443
  try {
3095
- grid = await demGrid(src, view, W, H, want);
3444
+ grid = await demGrid(src, g, vw.win, want, polar ? 0 : 360 * vw.k, polar);
3096
3445
  grid.noData = src.noData;
3097
3446
  name = fileName(ref.startsWith('https:') ? ref : bmUrl(ref)).replace(/(_cog)?\.tif$/i, '');
3098
3447
  break;
@@ -3106,7 +3455,11 @@
3106
3455
  if (!(hi > lo)) { st.contour = null; paths.forEach((p) => p.removeAttribute('d')); cinfo.textContent = grid ? '| no relief in view' : ''; return; }
3107
3456
  const step = step125(hi - lo, 10);
3108
3457
  const X = (x) => W / 2 + (x - view.cx) * view.k, Y = (y) => H / 2 + (y - view.cy) * view.k;
3109
- const d = contourPaths(grid.parts, grid.noData, step, X, Y);
3458
+ const P = nat ? (x, y) => [X(x), Y(y)] : (x, y) => {
3459
+ const q = polar ? pj.fwd(...psLonLat(x, -y, vw.fam)) : pj.fwd(x, -y);
3460
+ return q && [X(q[0]), Y(q[1])];
3461
+ };
3462
+ const d = contourPaths(grid.parts, grid.noData, step, P);
3110
3463
  paths.forEach((p, i) => p.setAttribute('d', d[i]));
3111
3464
  st.contour = { ...view, W, H };
3112
3465
  contours.removeAttribute('transform');
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lunar-toolkit
3
- Version: 0.1.0.dev13
3
+ Version: 0.1.0.dev14
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lunar-toolkit"
7
- version = "0.1.0.dev13"
7
+ version = "0.1.0.dev14"
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"