lunar-toolkit 0.1.0.dev2__py3-none-any.whl

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 (55) hide show
  1. lunar_toolkit/__init__.py +6 -0
  2. lunar_toolkit/asp.py +432 -0
  3. lunar_toolkit/cli/__init__.py +88 -0
  4. lunar_toolkit/cli/_jobs.py +437 -0
  5. lunar_toolkit/cli/_status.py +368 -0
  6. lunar_toolkit/cli/_utils.py +128 -0
  7. lunar_toolkit/cli/app.py +205 -0
  8. lunar_toolkit/cli/build_db.py +130 -0
  9. lunar_toolkit/cli/download.py +111 -0
  10. lunar_toolkit/cli/kaguya.py +253 -0
  11. lunar_toolkit/cli/ode_search.py +144 -0
  12. lunar_toolkit/cli/pairs.py +236 -0
  13. lunar_toolkit/cli/process.py +138 -0
  14. lunar_toolkit/cli/query.py +70 -0
  15. lunar_toolkit/cli/reference.py +233 -0
  16. lunar_toolkit/cli/search.py +90 -0
  17. lunar_toolkit/cli/serve.py +706 -0
  18. lunar_toolkit/cli/sfs.py +344 -0
  19. lunar_toolkit/cli/sfs_report.py +148 -0
  20. lunar_toolkit/cli/shadowcam.py +246 -0
  21. lunar_toolkit/cli/stereo.py +85 -0
  22. lunar_toolkit/cli/workspace.py +84 -0
  23. lunar_toolkit/config.py +345 -0
  24. lunar_toolkit/constants.py +217 -0
  25. lunar_toolkit/csm.py +340 -0
  26. lunar_toolkit/downloader.py +447 -0
  27. lunar_toolkit/geometry.py +556 -0
  28. lunar_toolkit/isis.py +222 -0
  29. lunar_toolkit/kaguya/__init__.py +45 -0
  30. lunar_toolkit/kaguya/mi.py +274 -0
  31. lunar_toolkit/kaguya/tc.py +370 -0
  32. lunar_toolkit/lroc/__init__.py +6 -0
  33. lunar_toolkit/lroc/cumindex.py +1197 -0
  34. lunar_toolkit/lroc/nac.py +702 -0
  35. lunar_toolkit/observation.py +127 -0
  36. lunar_toolkit/ode.py +485 -0
  37. lunar_toolkit/provenance.py +312 -0
  38. lunar_toolkit/reference.py +1111 -0
  39. lunar_toolkit/selection.py +714 -0
  40. lunar_toolkit/sfs.py +1534 -0
  41. lunar_toolkit/sfs_products.py +676 -0
  42. lunar_toolkit/sfs_report.py +1328 -0
  43. lunar_toolkit/shadowcam/__init__.py +29 -0
  44. lunar_toolkit/shadowcam/sc.py +392 -0
  45. lunar_toolkit/static/index.html +3259 -0
  46. lunar_toolkit/stereo.py +519 -0
  47. lunar_toolkit/stereo_dem.py +145 -0
  48. lunar_toolkit/tools.py +264 -0
  49. lunar_toolkit/workspace.py +1173 -0
  50. lunar_toolkit-0.1.0.dev2.dist-info/METADATA +177 -0
  51. lunar_toolkit-0.1.0.dev2.dist-info/RECORD +55 -0
  52. lunar_toolkit-0.1.0.dev2.dist-info/WHEEL +5 -0
  53. lunar_toolkit-0.1.0.dev2.dist-info/entry_points.txt +2 -0
  54. lunar_toolkit-0.1.0.dev2.dist-info/licenses/LICENSE +28 -0
  55. lunar_toolkit-0.1.0.dev2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3259 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Lunar Toolkit Browser</title>
7
+ <style>
8
+ /* Fusion palette (light), dark variant below */
9
+ :root {
10
+ --window: #efefef;
11
+ --base: #ffffff;
12
+ --alt: #f6f6f6;
13
+ --text: #1b1b1b;
14
+ --muted: #5f5f5f;
15
+ --disabled: #9a9a9a;
16
+ --mid: #b5b5b5;
17
+ --dark: #8e8e8e;
18
+ --light: #ffffff;
19
+ --gridline: #dcdcdc;
20
+ --btn-top: #fbfbfb;
21
+ --btn-bot: #e6e6e6;
22
+ --btn-press-top: #d9d9d9;
23
+ --btn-press-bot: #e9e9e9;
24
+ --hdr-top: #fafafa;
25
+ --hdr-bot: #e4e4e4;
26
+ --highlight: #308cc6;
27
+ --hl-text: #ffffff;
28
+ --link: #0b5fa5;
29
+ --error: #b00020;
30
+ --plot-bg: #ffffff;
31
+ --plot-fg: #000000;
32
+ --plot-grid: #d9d9d9;
33
+ --plot-label: #303030;
34
+ --fp: #1f5f8b;
35
+ --fp-sel: #d62728;
36
+ --region: #008a00;
37
+ --view-bg: #6e6e6e;
38
+ --view-fg: #f0f0f0;
39
+ }
40
+ @media (prefers-color-scheme: dark) {
41
+ :root:not([data-theme="light"]) {
42
+ --window: #353535;
43
+ --base: #252525;
44
+ --alt: #2c2c2c;
45
+ --text: #ffffff;
46
+ --muted: #b4b4b4;
47
+ --disabled: #7f7f7f;
48
+ --mid: #232323;
49
+ --dark: #1b1b1b;
50
+ --light: #4a4a4a;
51
+ --gridline: #3a3a3a;
52
+ --btn-top: #454545;
53
+ --btn-bot: #393939;
54
+ --btn-press-top: #2e2e2e;
55
+ --btn-press-bot: #363636;
56
+ --hdr-top: #434343;
57
+ --hdr-bot: #383838;
58
+ --highlight: #2a82da;
59
+ --hl-text: #ffffff;
60
+ --link: #6cb0f5;
61
+ --error: #ff6b6b;
62
+ --plot-bg: #1e1e1e;
63
+ --plot-fg: #d8d8d8;
64
+ --plot-grid: #3c3c3c;
65
+ --plot-label: #cfcfcf;
66
+ --fp: #6fa8dc;
67
+ --fp-sel: #ff6b5e;
68
+ --region: #4fd14f;
69
+ --view-bg: #161616;
70
+ --view-fg: #b4b4b4;
71
+ }
72
+ }
73
+ :root[data-theme="dark"] {
74
+ --window: #353535; --base: #252525; --alt: #2c2c2c; --text: #ffffff; --muted: #b4b4b4;
75
+ --disabled: #7f7f7f; --mid: #232323; --dark: #1b1b1b; --light: #4a4a4a; --gridline: #3a3a3a;
76
+ --btn-top: #454545; --btn-bot: #393939; --btn-press-top: #2e2e2e; --btn-press-bot: #363636;
77
+ --hdr-top: #434343; --hdr-bot: #383838; --highlight: #2a82da; --hl-text: #ffffff; --link: #6cb0f5;
78
+ --error: #ff6b6b; --plot-bg: #1e1e1e; --plot-fg: #d8d8d8; --plot-grid: #3c3c3c; --plot-label: #cfcfcf;
79
+ --fp: #6fa8dc; --fp-sel: #ff6b5e; --region: #4fd14f; --view-bg: #161616; --view-fg: #b4b4b4;
80
+ }
81
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
82
+ html, body { height: 100%; }
83
+ body {
84
+ font: 12px "Segoe UI", "DejaVu Sans", "Liberation Sans", Arial, sans-serif;
85
+ background: var(--window);
86
+ color: var(--text);
87
+ overflow: hidden;
88
+ }
89
+ code, .mono, td.mono, textarea, input.ids {
90
+ font-family: "DejaVu Sans Mono", Consolas, "Liberation Mono", monospace; font-size: 11px;
91
+ }
92
+ a { color: var(--link); }
93
+ [hidden] { display: none !important; }
94
+
95
+ /* Main window: title bar, control panel | workspace, status bar */
96
+ .app { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; height: 100vh; }
97
+ .titlebar {
98
+ display: flex; align-items: center; gap: 8px; padding: 3px 8px; min-height: 26px;
99
+ background: linear-gradient(var(--btn-top), var(--window)); border-bottom: 1px solid var(--mid);
100
+ }
101
+ .titlebar .name { font-weight: bold; }
102
+ .titlebar .sub { color: var(--muted); }
103
+ .titlebar .sources { margin-left: auto; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
104
+ .titlebar .sources b { color: var(--text); font-weight: normal; }
105
+ .vsep { width: 1px; align-self: stretch; margin: 2px 2px; background: var(--mid); box-shadow: 1px 0 0 var(--light); }
106
+ .main { display: grid; grid-template-columns: 340px minmax(0, 1fr); min-height: 0; }
107
+
108
+ .controls { display: flex; flex-direction: column; min-height: 0; padding: 6px 4px 6px 6px; }
109
+ .tabbar { display: flex; padding-left: 4px; position: relative; z-index: 1; }
110
+ .tabbar button {
111
+ font: inherit; color: var(--text); padding: 3px 12px; margin-right: -1px; margin-top: 2px;
112
+ background: linear-gradient(var(--btn-bot), var(--btn-press-top)); border: 1px solid var(--mid);
113
+ border-radius: 2px 2px 0 0; cursor: default;
114
+ }
115
+ .tabbar button[aria-selected="true"] {
116
+ background: var(--window); border-bottom-color: var(--window); margin-top: 0; padding-bottom: 5px;
117
+ margin-bottom: -1px; position: relative; z-index: 1;
118
+ }
119
+ .tabbar button:focus-visible { outline: 1px dotted var(--text); outline-offset: -4px; }
120
+ .tabpane {
121
+ flex: 1; min-height: 0; overflow: auto; border: 1px solid var(--mid); border-radius: 0 2px 2px 2px;
122
+ background: var(--window); padding: 4px 6px 6px; box-shadow: inset 1px 1px 0 var(--light);
123
+ }
124
+
125
+ fieldset { border: 1px solid var(--mid); border-radius: 2px; padding: 6px 7px 7px; margin-top: 6px; box-shadow: inset 1px 1px 0 var(--light), 1px 1px 0 var(--light); }
126
+ legend { padding: 0 3px; margin-left: 4px; }
127
+ .form { display: grid; grid-template-columns: 6.6em minmax(0, 1fr) minmax(0, 1fr); gap: 4px 5px; align-items: center; }
128
+ .form > [data-mode] { display: contents; }
129
+ .form > label, .form > .lbl { color: var(--text); white-space: nowrap; }
130
+ .form .span2 { grid-column: span 2; }
131
+ .form .span3 { grid-column: 1 / -1; }
132
+ .form .buttons { grid-column: 1 / -1; display: flex; justify-content: flex-end; gap: 6px; margin-top: 2px; }
133
+ .stack { display: flex; flex-direction: column; gap: 5px; }
134
+ .hint { color: var(--muted); font-size: 11px; line-height: 1.35; margin-top: 6px; }
135
+ .hint code { font-size: 10.5px; }
136
+
137
+ input, select, textarea {
138
+ font: inherit; color: var(--text); background: var(--base); min-width: 0; width: 100%;
139
+ border: 1px solid var(--mid); border-top-color: var(--dark); border-radius: 2px;
140
+ padding: 2px 4px; height: 22px;
141
+ }
142
+ textarea { height: auto; min-height: 5.5em; resize: vertical; line-height: 1.3; }
143
+ input:focus, select:focus, textarea:focus { outline: none; border-color: var(--highlight); box-shadow: 0 0 0 1px var(--highlight) inset; }
144
+ input::placeholder, textarea::placeholder { color: var(--disabled); }
145
+ select {
146
+ appearance: none; padding-right: 18px; cursor: default;
147
+ background:
148
+ linear-gradient(45deg, transparent 50%, var(--text) 50%) right 10px top 9px / 4px 4px no-repeat,
149
+ linear-gradient(135deg, var(--text) 50%, transparent 50%) right 6px top 9px / 4px 4px no-repeat,
150
+ linear-gradient(var(--btn-top), var(--btn-bot));
151
+ border-top-color: var(--mid); border-bottom-color: var(--dark);
152
+ }
153
+ select option { background: var(--base); color: var(--text); }
154
+ input[type="file"] { padding: 1px; height: 24px; border: none; background: none; color: var(--muted); }
155
+ button, input[type="file"]::file-selector-button {
156
+ font: inherit; color: var(--text); white-space: nowrap; cursor: default;
157
+ background: linear-gradient(var(--btn-top), var(--btn-bot));
158
+ border: 1px solid var(--mid); border-bottom-color: var(--dark); border-radius: 2px;
159
+ padding: 2px 12px; min-height: 22px; min-width: 72px;
160
+ }
161
+ input[type="file"]::file-selector-button { margin-right: 6px; min-width: 0; }
162
+ button:hover:not(:disabled), input[type="file"]::file-selector-button:hover { background: linear-gradient(var(--light), var(--btn-top)); }
163
+ button:active:not(:disabled) { background: linear-gradient(var(--btn-press-top), var(--btn-press-bot)); }
164
+ button.default { border-color: var(--highlight); }
165
+ button:focus-visible { outline: 1px dotted var(--text); outline-offset: -4px; }
166
+ button:disabled { color: var(--disabled); }
167
+ button.tool { min-width: 0; padding: 0 8px; min-height: 18px; font-size: 11px; }
168
+
169
+ /* Workspace: map over (results | details), with splitters */
170
+ .workspace {
171
+ display: grid; grid-template-rows: minmax(140px, var(--map-h, 55%)) 6px minmax(0, 1fr);
172
+ min-height: 0; padding: 6px 6px 4px 2px;
173
+ }
174
+ .lower { display: grid; grid-template-columns: minmax(0, 1fr) 6px minmax(200px, var(--det-w, 380px)); min-height: 0; }
175
+ .pane { display: flex; flex-direction: column; min-height: 0; min-width: 0; border: 1px solid var(--mid); background: var(--base); }
176
+ .panehead {
177
+ display: flex; align-items: center; gap: 6px; padding: 1px 2px 1px 6px; min-height: 22px;
178
+ background: linear-gradient(var(--hdr-top), var(--hdr-bot)); border-bottom: 1px solid var(--mid);
179
+ }
180
+ .panehead .title { font-weight: bold; }
181
+ .panehead .info { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
182
+ .panehead .right { margin-left: auto; display: flex; gap: 3px; }
183
+ .panebody { flex: 1; min-height: 0; overflow: auto; position: relative; }
184
+ .panebody:empty::before, .mapbox:empty::before {
185
+ content: attr(data-empty); position: absolute; inset: 0; display: flex; align-items: center;
186
+ justify-content: center; color: var(--disabled); padding: 1em; text-align: center;
187
+ }
188
+ .splitter { position: relative; touch-action: none; }
189
+ .splitter.rows { cursor: row-resize; }
190
+ .splitter.cols { cursor: col-resize; }
191
+ .splitter::after { content: ''; position: absolute; inset: 0; margin: auto; }
192
+ .splitter.rows::after { width: 24px; height: 2px; border-top: 1px dotted var(--dark); border-bottom: 1px dotted var(--dark); }
193
+ .splitter.cols::after { height: 24px; width: 2px; border-left: 1px dotted var(--dark); border-right: 1px dotted var(--dark); }
194
+
195
+ /* QTableView-like grids */
196
+ table { border-collapse: separate; border-spacing: 0; }
197
+ table.grid { min-width: 100%; }
198
+ table.grid th {
199
+ position: sticky; top: 0; z-index: 1; text-align: left; font-weight: normal; white-space: nowrap;
200
+ padding: 2px 6px; background: linear-gradient(var(--hdr-top), var(--hdr-bot));
201
+ border-right: 1px solid var(--mid); border-bottom: 1px solid var(--mid); box-shadow: inset 1px 1px 0 var(--light);
202
+ }
203
+ table.grid td { padding: 1px 6px; height: 20px; white-space: nowrap; border-right: 1px solid var(--gridline); border-bottom: 1px solid var(--gridline); }
204
+ table.grid tbody tr:nth-child(even) td { background: var(--alt); }
205
+ table.grid tbody tr { cursor: default; }
206
+ table.grid tbody tr.selected td { background: var(--highlight); color: var(--hl-text); }
207
+ table.grid tbody tr.selected a { color: var(--hl-text); }
208
+ table.grid th.num, table.grid td.num { text-align: right; font-variant-numeric: tabular-nums; }
209
+ .tablewrap:focus { outline: none; }
210
+ .tablewrap:focus-visible tr.selected td { outline: 1px dotted var(--hl-text); outline-offset: -2px; }
211
+
212
+ .detail { border-bottom: 1px solid var(--mid); }
213
+ .detail h2 {
214
+ font: bold 11px "DejaVu Sans Mono", Consolas, "Liberation Mono", monospace; padding: 3px 6px;
215
+ background: var(--window); border-bottom: 1px solid var(--gridline);
216
+ }
217
+ .detail table { width: 100%; table-layout: fixed; }
218
+ .detail th {
219
+ text-align: left; font-weight: normal; padding: 1px 6px; background: linear-gradient(var(--hdr-top), var(--hdr-bot));
220
+ border-bottom: 1px solid var(--mid); border-right: 1px solid var(--mid);
221
+ }
222
+ .detail td { padding: 1px 6px; border-bottom: 1px solid var(--gridline); vertical-align: top; overflow-wrap: anywhere; }
223
+ .detail td.label { width: 42%; color: var(--muted); background: var(--alt); border-right: 1px solid var(--gridline); }
224
+ .detail a { word-break: break-all; }
225
+ .not-found { padding: 4px 6px; color: var(--error); }
226
+
227
+ /* Footprint plot */
228
+ .mapbox { height: 100%; position: relative; }
229
+ .map { display: flex; flex-direction: column; height: 100%; }
230
+ .map svg { display: block; flex: 1 1 0; min-height: 0; width: 100%; touch-action: none; user-select: none; cursor: crosshair; }
231
+ .map svg.dragging { cursor: grabbing; }
232
+ .map .plot { fill: var(--plot-bg); }
233
+ .map .frame { fill: none; stroke: var(--plot-fg); stroke-width: 1; shape-rendering: crispEdges; }
234
+ .map .tick { fill: none; stroke: var(--plot-fg); stroke-width: 1; shape-rendering: crispEdges; }
235
+ .map .grid { fill: none; stroke: var(--plot-grid); stroke-width: 1; }
236
+ .map .fp { fill: var(--fp); fill-opacity: 0.08; fill-rule: evenodd; stroke: var(--fp); stroke-width: 0.8; cursor: pointer; }
237
+ .map .fp:hover { fill-opacity: 0.25; stroke-width: 1.3; }
238
+ .map .fp.sel { fill: var(--fp-sel); fill-opacity: 0.3; stroke: var(--fp-sel); stroke-width: 1.8; }
239
+ .map .region { fill: none; fill-rule: evenodd; stroke: var(--region); stroke-width: 1.4; stroke-dasharray: 6 3; pointer-events: none; }
240
+ .map text { fill: var(--plot-label); font-size: 10.5px; pointer-events: none; }
241
+ .map text.gl { fill: var(--muted); font-size: 10px; }
242
+ .map text.axis { font-size: 11px; }
243
+
244
+ /* Top pane: Footprints | Image tabs; the image view is a framed canvas under a tool bar */
245
+ .viewwrap { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
246
+ .viewwrap > .tabbar { padding-left: 2px; }
247
+ .viewwrap > .pane { flex: 1; }
248
+ .viewbody { flex: 1; min-height: 0; display: flex; flex-direction: column; }
249
+ .viewbody > .mapbox { flex: 1; min-height: 0; height: auto; }
250
+ .toolbar {
251
+ display: flex; flex-wrap: wrap; align-items: center; gap: 3px; padding: 2px 4px;
252
+ background: var(--window); border-bottom: 1px solid var(--mid); box-shadow: inset 0 1px 0 var(--light);
253
+ }
254
+ .toolbar select, .toolbar input { width: auto; height: 20px; padding-top: 0; padding-bottom: 0; }
255
+ .toolbar select { max-width: 24em; background-position: right 10px top 8px, right 6px top 8px, 0 0; }
256
+ .toolbar input[type="number"] { width: 6em; }
257
+ .toolbar input:disabled { color: var(--disabled); }
258
+ .toolbar .lbl { color: var(--text); margin-left: 3px; }
259
+ .toolbar .sep { width: 1px; align-self: stretch; margin: 1px 3px; background: var(--mid); box-shadow: 1px 0 0 var(--light); }
260
+ .toolbar button.tool { min-height: 20px; }
261
+ .viewframe { flex: 1; min-height: 0; position: relative; overflow: hidden; background: var(--view-bg); box-shadow: inset 1px 1px 0 var(--dark); }
262
+ .viewframe canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; cursor: crosshair; touch-action: none; outline: none; }
263
+ .viewframe canvas.dragging { cursor: grabbing; }
264
+ .viewframe canvas:focus-visible { box-shadow: inset 0 0 0 1px var(--highlight); }
265
+ .viewmsg {
266
+ position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 1em;
267
+ color: var(--view-fg); text-align: center; pointer-events: none; line-height: 1.4;
268
+ }
269
+ .viewmsg:empty { display: none; }
270
+ .viewmsg.error {
271
+ inset: auto 8px 8px 8px; justify-content: flex-start; text-align: left; padding: 4px 8px;
272
+ color: var(--error); background: var(--base); border: 1px solid var(--mid); border-left: 3px solid var(--error);
273
+ }
274
+
275
+ /* Quick look in the Details pane */
276
+ .preview { padding: 4px 6px 5px; border-bottom: 1px solid var(--gridline); background: var(--window); }
277
+ .pvbox {
278
+ display: flex; align-items: center; justify-content: center; min-height: 48px; overflow: hidden;
279
+ background: var(--view-bg); border: 1px solid var(--mid); border-top-color: var(--dark);
280
+ }
281
+ .pvbox canvas, .pvbox img { display: block; max-width: 100%; max-height: 240px; }
282
+ .pvbox .msg { color: var(--view-fg); padding: 8px; font-size: 11px; text-align: center; }
283
+ .pvbox .msg.error { color: var(--error); background: var(--base); align-self: stretch; width: 100%; text-align: left; }
284
+ .pvcap { display: flex; align-items: center; gap: 6px; margin-top: 3px; min-width: 0; }
285
+ .pvcap .info { color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
286
+ .pvcap .right { margin-left: auto; display: flex; gap: 3px; }
287
+ .preview.none { color: var(--muted); font-size: 11px; }
288
+
289
+ .statusbar {
290
+ display: flex; align-items: stretch; gap: 3px; padding: 2px 4px; min-height: 22px;
291
+ border-top: 1px solid var(--mid); box-shadow: inset 0 1px 0 var(--light); background: var(--window);
292
+ }
293
+ .statusbar > span { padding: 1px 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
294
+ .statusbar .status { flex: 1; min-width: 0; }
295
+ .statusbar .status.error { color: var(--error); }
296
+ .statusbar .cell { border-left: 1px solid var(--mid); box-shadow: inset 1px 0 0 var(--light); }
297
+ .statusbar #cursor { min-width: 22em; font-family: "DejaVu Sans Mono", Consolas, "Liberation Mono", monospace; font-size: 11px; }
298
+
299
+ /* Settings view: group boxes over the whole workspace */
300
+ .workspace.settings-on { grid-template-rows: minmax(0, 1fr); }
301
+ .workspace.settings-on > .splitter, .workspace.settings-on > .lower { display: none; }
302
+ .settings {
303
+ flex: 1; min-height: 0; overflow: auto; padding: 0 8px 8px; background: var(--window);
304
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 460px), 1fr)); gap: 0 10px; align-content: start;
305
+ }
306
+ .settings fieldset { min-width: 0; }
307
+ .settings .wide { grid-column: 1 / -1; }
308
+ .settings .tablebox { border: 1px solid var(--mid); border-top-color: var(--dark); background: var(--base); overflow: auto; max-height: 230px; margin-top: 5px; }
309
+ .settings .tablebox:empty { display: none; }
310
+ .settings .path { color: var(--muted); overflow-wrap: anywhere; margin-bottom: 3px; }
311
+ .settings .row { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin-top: 5px; }
312
+ .settings .row > .lbl { white-space: nowrap; }
313
+ .settings .row > input[type="text"], .settings .row > select { flex: 1 1 10em; width: auto; }
314
+ .settings .row > .push { margin-left: auto; display: flex; gap: 6px; }
315
+ .settings select[multiple] { height: auto; min-height: 7em; background: var(--base); padding: 1px; }
316
+ .settings td.actions { padding: 1px 2px; }
317
+ .settings td.actions button { margin-right: 2px; }
318
+ .checks { display: flex; flex-wrap: wrap; gap: 2px 12px; }
319
+ .checks label { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }
320
+ input[type="checkbox"] { width: auto; height: auto; margin: 0; accent-color: var(--highlight); }
321
+ .note { margin-top: 6px; padding: 4px 8px; background: var(--base); border: 1px solid var(--mid); border-left: 3px solid var(--highlight); line-height: 1.35; }
322
+ .settings .ok { color: var(--region); }
323
+ .settings .bad { color: var(--error); }
324
+ .settings .dim { color: var(--muted); }
325
+ pre.log { font: 10.5px "DejaVu Sans Mono", Consolas, "Liberation Mono", monospace; color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere; }
326
+ table.grid td.wrap { white-space: normal; }
327
+ .progress {
328
+ position: relative; height: 16px; min-width: 110px; overflow: hidden; border-radius: 2px;
329
+ border: 1px solid var(--mid); border-top-color: var(--dark); background: var(--base);
330
+ }
331
+ .progress > .bar { position: absolute; top: 0; bottom: 0; left: 0; background: linear-gradient(var(--highlight), color-mix(in srgb, var(--highlight) 78%, #000)); }
332
+ .progress > span { position: relative; display: block; text-align: center; font-size: 11px; line-height: 14px; }
333
+ .progress.busy > .bar { width: 28% !important; animation: busy 1.6s linear infinite; }
334
+ .progress.error > .bar { background: var(--error); }
335
+ @keyframes busy { from { left: -28%; } to { left: 100%; } }
336
+ #jobs-summary { cursor: pointer; }
337
+
338
+ @media (max-width: 800px) {
339
+ body { overflow: auto; }
340
+ .app { height: auto; min-height: 100vh; grid-template-rows: auto auto auto; }
341
+ .titlebar { flex-wrap: wrap; }
342
+ .titlebar .sources { margin-left: 0; flex-basis: 100%; white-space: normal; }
343
+ .titlebar .vsep { display: none; }
344
+ .main { grid-template-columns: minmax(0, 1fr); }
345
+ .controls { padding: 6px; }
346
+ .tabpane { overflow: visible; }
347
+ .workspace { display: flex; flex-direction: column; gap: 6px; padding: 0 6px 6px; }
348
+ .workspace > .viewwrap { height: 420px; flex: none; }
349
+ .workspace.settings-on > .viewwrap { height: auto; }
350
+ .settings { overflow: visible; }
351
+ .lower { display: flex; flex-direction: column; gap: 6px; }
352
+ .lower > .pane { height: 320px; flex: none; }
353
+ .splitter { display: none; }
354
+ .statusbar { position: sticky; bottom: 0; flex-wrap: wrap; }
355
+ .statusbar .status { flex-basis: 100%; white-space: normal; }
356
+ .statusbar #cursor { min-width: 0; }
357
+ }
358
+ </style>
359
+ </head>
360
+ <body>
361
+ <div class="app">
362
+ <header class="titlebar">
363
+ <span class="name">Lunar Toolkit Browser</span>
364
+ <span class="vsep"></span>
365
+ <span class="sub">LROC PDS index via DuckDB-WASM | ShadowCam, LROC, Kaguya via PDS ODE</span>
366
+ <span class="sources" id="sources">Looking for index files...</span>
367
+ </header>
368
+
369
+ <div class="main">
370
+ <aside class="controls">
371
+ <div class="tabbar" role="tablist">
372
+ <button role="tab" id="tab-index" data-for="index" aria-selected="true" aria-controls="panel-index">Local index</button>
373
+ <button role="tab" id="tab-ode" data-for="ode" aria-selected="false" aria-controls="panel-ode" tabindex="-1">ODE</button>
374
+ </div>
375
+
376
+ <section class="tabpane" id="panel-index" data-tab="index" role="tabpanel" aria-labelledby="tab-index">
377
+ <fieldset>
378
+ <legend>Index data</legend>
379
+ <div class="stack">
380
+ <input id="files" type="file" accept=".parquet" multiple aria-label="Open Parquet files">
381
+ </div>
382
+ <p class="hint">Open <code>cumindex_edr.parquet</code> / <code>_cdr</code> / <code>_rdr</code> from
383
+ <code>lunar build-db</code> (<code>~/.local/share/lunar_toolkit</code>); read in place, nothing is
384
+ uploaded. <code>lunar serve</code> loads them automatically; <code>?edr=URL&amp;cdr=URL&amp;rdr=URL</code>
385
+ reads hosted copies (CORS + Range required).</p>
386
+ </fieldset>
387
+
388
+ <form id="lookup-form">
389
+ <fieldset>
390
+ <legend>Look up product IDs</legend>
391
+ <div class="form">
392
+ <input id="ids" class="ids span3" type="text" autocomplete="off" spellcheck="false"
393
+ aria-label="Product IDs" placeholder="M1096757719LE M1096757719RE ...">
394
+ <div class="buttons"><button id="lookup-btn" class="default" type="submit" disabled>Look up</button></div>
395
+ </div>
396
+ </fieldset>
397
+ </form>
398
+
399
+ <form id="index-search-form">
400
+ <fieldset>
401
+ <legend>Search footprints</legend>
402
+ <div class="form">
403
+ <label for="ix-dataset">Dataset</label><select id="ix-dataset" class="span2"></select>
404
+ <label for="ix-camera">Camera</label>
405
+ <select id="ix-camera" class="span2">
406
+ <option value="all">All</option>
407
+ <option value="nac">NAC</option>
408
+ <option value="wac">WAC</option>
409
+ </select>
410
+ <label for="ix-limit">Limit</label><input id="ix-limit" class="span2" type="number" value="200" min="1" max="5000">
411
+ <label for="ix-mode">Region</label>
412
+ <select id="ix-mode" class="span2">
413
+ <option value="box">Bounding box</option>
414
+ <option value="point">Point + radius</option>
415
+ <option value="aoi">AOI polygon</option>
416
+ </select>
417
+ <div data-mode="box">
418
+ <span class="lbl">Lon W / E</span>
419
+ <input id="ix-west" type="number" step="any" value="0" aria-label="West" title="West">
420
+ <input id="ix-east" type="number" step="any" value="360" aria-label="East" title="East">
421
+ <span class="lbl">Lat S / N</span>
422
+ <input id="ix-south" type="number" step="any" value="-90" aria-label="South" title="South">
423
+ <input id="ix-north" type="number" step="any" value="-89" aria-label="North" title="North">
424
+ </div>
425
+ <div data-mode="point" hidden>
426
+ <label for="ix-lat">Lat</label><input id="ix-lat" class="span2" type="number" step="any" value="-89.67">
427
+ <label for="ix-lon">Lon</label><input id="ix-lon" class="span2" type="number" step="any" value="129.8">
428
+ <label for="ix-radius">Radius (km)</label><input id="ix-radius" class="span2" type="number" step="any" min="0" value="15">
429
+ </div>
430
+ <div data-mode="aoi" hidden>
431
+ <label for="ix-aoi-file">AOI file</label>
432
+ <input id="ix-aoi-file" class="span2" type="file" accept=".geojson,.json,.wkt,.txt">
433
+ <textarea id="ix-aoi" class="span3" spellcheck="false" aria-label="AOI polygon"
434
+ placeholder="or paste WKT / GeoJSON, e.g. POLYGON((350 -5, 10 -5, 10 5, 350 5, 350 -5))"></textarea>
435
+ </div>
436
+ <span class="lbl">Incidence</span>
437
+ <input id="ix-inc-min" type="number" step="any" placeholder="min" aria-label="Incidence min">
438
+ <input id="ix-inc-max" type="number" step="any" placeholder="max" aria-label="Incidence max">
439
+ <span class="lbl">Emission</span>
440
+ <input id="ix-emi-min" type="number" step="any" placeholder="min" aria-label="Emission min">
441
+ <input id="ix-emi-max" type="number" step="any" placeholder="max" aria-label="Emission max">
442
+ <div class="buttons"><button id="ix-search-btn" class="default" type="submit" disabled>Search</button></div>
443
+ </div>
444
+ <p class="hint">Degrees, longitudes positive east (0-360 or -180-180); WEST &gt; EAST crosses the seam.
445
+ Same matching as <code>lunar search --bbox / --point / --aoi</code>: conservative bounding-box test
446
+ (polar stereographic poleward of 60 deg), and for an AOI an exact polygon intersection.</p>
447
+ </fieldset>
448
+ </form>
449
+ </section>
450
+
451
+ <section class="tabpane" id="panel-ode" data-tab="ode" role="tabpanel" aria-labelledby="tab-ode" hidden>
452
+ <form id="ode-form">
453
+ <fieldset>
454
+ <legend>Search ODE</legend>
455
+ <div class="form">
456
+ <label for="ode-instrument">Instrument</label><select id="ode-instrument" class="span2"></select>
457
+ <label for="ode-type">Product type</label><select id="ode-type" class="span2"></select>
458
+ <label for="ode-limit">Per page</label><input id="ode-limit" class="span2" type="number" value="100" min="1" max="1000">
459
+ <label for="ode-mode">Region</label>
460
+ <select id="ode-mode" class="span2">
461
+ <option value="box">Bounding box</option>
462
+ <option value="point">Point + radius</option>
463
+ <option value="aoi">AOI polygon</option>
464
+ </select>
465
+ <div data-mode="box">
466
+ <span class="lbl">Lon W / E</span>
467
+ <input id="ode-west" type="number" step="any" value="0" aria-label="West" title="West">
468
+ <input id="ode-east" type="number" step="any" value="360" aria-label="East" title="East">
469
+ <span class="lbl">Lat S / N</span>
470
+ <input id="ode-south" type="number" step="any" value="-90" aria-label="South" title="South">
471
+ <input id="ode-north" type="number" step="any" value="-89.5" aria-label="North" title="North">
472
+ </div>
473
+ <div data-mode="point" hidden>
474
+ <label for="ode-lat">Lat</label><input id="ode-lat" class="span2" type="number" step="any" value="-89.67">
475
+ <label for="ode-lon">Lon</label><input id="ode-lon" class="span2" type="number" step="any" value="129.8">
476
+ <label for="ode-radius">Radius (km)</label><input id="ode-radius" class="span2" type="number" step="any" min="0" value="15">
477
+ </div>
478
+ <div data-mode="aoi" hidden>
479
+ <label for="ode-aoi-file">AOI file</label>
480
+ <input id="ode-aoi-file" class="span2" type="file" accept=".geojson,.json,.wkt,.txt">
481
+ <textarea id="ode-aoi" class="span3" spellcheck="false" aria-label="AOI polygon"
482
+ placeholder="or paste WKT / GeoJSON, e.g. POLYGON((350 -5, 10 -5, 10 5, 350 5, 350 -5))"></textarea>
483
+ </div>
484
+ <span class="lbl">Incidence</span>
485
+ <input id="ode-inc-min" type="number" step="any" placeholder="min" aria-label="Incidence min">
486
+ <input id="ode-inc-max" type="number" step="any" placeholder="max" aria-label="Incidence max">
487
+ <span class="lbl">Emission</span>
488
+ <input id="ode-emi-min" type="number" step="any" placeholder="min" aria-label="Emission min">
489
+ <input id="ode-emi-max" type="number" step="any" placeholder="max" aria-label="Emission max">
490
+ <label for="ode-t0">From</label><input id="ode-t0" class="span2" type="date">
491
+ <label for="ode-t1">To</label><input id="ode-t1" class="span2" type="date">
492
+ <div class="buttons"><button id="ode-btn" class="default" type="submit">Search</button></div>
493
+ </div>
494
+ <p class="hint">ODE gets the region's bounding box (a circle becomes its box); with an AOI each
495
+ page is then filtered by exact footprint intersection, like <code>lunar ode-search --aoi</code>.</p>
496
+ </fieldset>
497
+ </form>
498
+ </section>
499
+ </aside>
500
+
501
+ <main class="workspace" id="workspace">
502
+ <div class="viewwrap">
503
+ <div class="tabbar" role="tablist" aria-label="View">
504
+ <button role="tab" id="vtab-map" data-view="map" aria-selected="true" aria-controls="view-map">Footprints</button>
505
+ <button role="tab" id="vtab-image" data-view="image" aria-selected="false" aria-controls="view-image" tabindex="-1">Image</button>
506
+ <button role="tab" id="vtab-settings" data-view="settings" aria-selected="false" aria-controls="view-settings" tabindex="-1">Settings</button>
507
+ </div>
508
+ <section class="pane">
509
+ <div class="viewbody" id="view-map" role="tabpanel" aria-labelledby="vtab-map">
510
+ <div class="mapbox" id="index-map" data-tab="index" data-empty="No footprints. Look up product IDs or run a search."></div>
511
+ <div class="mapbox" id="ode-map" data-tab="ode" data-empty="No footprints. Run an ODE search." hidden></div>
512
+ </div>
513
+ <div class="viewbody" id="view-image" role="tabpanel" aria-labelledby="vtab-image" hidden>
514
+ <div class="panehead">
515
+ <span class="title">Image</span>
516
+ <span class="info" id="view-info">No image</span>
517
+ </div>
518
+ <div class="toolbar" role="toolbar" aria-label="Image view">
519
+ <select id="view-source" aria-label="Image source" title="Image source" disabled><option value="">(no image)</option></select>
520
+ <button class="tool" id="view-open" type="button" title="Open a local GeoTIFF / COG (or PNG / JPEG)">Open file...</button>
521
+ <input id="view-file" type="file" accept=".tif,.tiff,image/tiff,.png,.jpg,.jpeg" hidden>
522
+ <span class="sep"></span>
523
+ <button class="tool" id="view-zoom-out" type="button" title="Zoom out (-)">-</button>
524
+ <button class="tool" id="view-zoom-in" type="button" title="Zoom in (+)">+</button>
525
+ <button class="tool" id="view-1to1" type="button" title="Full resolution: one image pixel per screen pixel (0)">1:1</button>
526
+ <button class="tool" id="view-fit" type="button" title="Fit the whole image (F)">Fit</button>
527
+ <button class="tool" id="view-fit-width" type="button" title="Fit the image width (W)">Fit width</button>
528
+ <span class="sep"></span>
529
+ <label class="lbl" for="view-min">Min</label><input id="view-min" type="number" step="any" disabled>
530
+ <label class="lbl" for="view-max">Max</label><input id="view-max" type="number" step="any" disabled>
531
+ <label class="lbl" for="view-gamma">Gamma</label><input id="view-gamma" type="number" step="0.1" min="0.1" max="10" value="1" disabled>
532
+ <button class="tool" id="view-auto" type="button" title="Stretch the 2nd - 98th percentile of the overview" disabled>Auto 2-98%</button>
533
+ </div>
534
+ <div class="viewframe" id="view-frame">
535
+ <canvas id="view-canvas" tabindex="0" aria-label="Image (wheel zoom, drag pan, + - 0 F W keys)"></canvas>
536
+ <div class="viewmsg" id="view-msg">Select a product with a browse image (LROC NAC / WAC pyramid, ShadowCam COG,
537
+ Kaguya JPEG), open a local GeoTIFF, or start <code>lunar serve --images DIR</code>.</div>
538
+ </div>
539
+ </div>
540
+ <div class="viewbody" id="view-settings" role="tabpanel" aria-labelledby="vtab-settings" hidden>
541
+ <div class="panehead">
542
+ <span class="title">Settings</span>
543
+ <span class="info" id="set-info">Needs lunar serve or lunar app</span>
544
+ <span class="right"><button class="tool" id="set-refresh" type="button" title="Read the status again (sizes, tools)">Refresh</button></span>
545
+ </div>
546
+ <div class="settings" id="settings">
547
+ <p class="note wide" id="set-note" hidden></p>
548
+
549
+ <fieldset class="wide">
550
+ <legend>Data and indexes</legend>
551
+ <div class="path" id="set-data-dir"></div>
552
+ <div class="tablebox" id="set-indexes"></div>
553
+ <div class="row">
554
+ <label class="lbl" for="set-release">Release</label>
555
+ <input id="set-release" type="text" class="mono" style="max-width: 24em" placeholder="newest (e.g. LROLRC_0066C)" spellcheck="false" data-admin>
556
+ <span class="checks"><label><input id="set-volume-only" type="checkbox" data-admin> Volume index only</label></span>
557
+ </div>
558
+ <p class="hint">Build downloads the cumulative CUMINDEX.TAB (EDR about 3.8 GB) and writes the Parquet with
559
+ DuckDB; "Volume index only" uses one release's INDEX.TAB (about 20 k rows). Update appends the volumes
560
+ released since the build. Same as <code>lunar build-db</code>.</p>
561
+ </fieldset>
562
+
563
+ <fieldset class="wide">
564
+ <legend>Jobs</legend>
565
+ <div class="tablebox" id="set-jobs"></div>
566
+ <p class="hint" id="set-jobs-empty">No jobs yet. Builds and downloads run here in the background (two at a time)
567
+ and resume from their .part files when started again.</p>
568
+ </fieldset>
569
+
570
+ <fieldset>
571
+ <legend>Reference data</legend>
572
+ <div class="row">
573
+ <label class="lbl" for="set-region">Region</label>
574
+ <select id="set-region" data-admin></select>
575
+ </div>
576
+ <div class="row">
577
+ <span class="lbl">PGDA LOLA 5 m</span>
578
+ <span class="checks" id="set-pgda-kinds"></span>
579
+ <span class="push"><button id="set-pgda-btn" type="button" data-admin>Download</button></span>
580
+ </div>
581
+ <div class="row">
582
+ <label class="lbl" for="set-sdem-pat">SfS DEM members</label>
583
+ <input id="set-sdem-pat" type="text" class="mono" value="GLDELEV" spellcheck="false" data-admin
584
+ title="Product codes / substrings or globs, e.g. GLDELEV GLDSIGM README">
585
+ <span class="push">
586
+ <button id="set-sdem-list" type="button" data-admin title="Read the Zenodo zip directory (HTTP Range)">List members</button>
587
+ <button id="set-sdem-btn" type="button" data-admin>Extract</button>
588
+ </span>
589
+ </div>
590
+ <select id="set-sdem-members" multiple aria-label="Zip members" hidden data-admin></select>
591
+ <div class="tablebox" id="set-reference"></div>
592
+ <p class="hint" id="set-ref-dir"></p>
593
+ </fieldset>
594
+
595
+ <fieldset>
596
+ <legend>Downloads</legend>
597
+ <textarea id="set-dl-ids" class="ids" rows="3" spellcheck="false" aria-label="Product IDs to download"
598
+ placeholder="M1096757719LE M1096757719RE ..." data-admin></textarea>
599
+ <div class="row">
600
+ <label class="lbl" for="set-dl-product">Product</label>
601
+ <select id="set-dl-product" style="flex: 0 0 auto; width: 7em" data-admin>
602
+ <option value="edr">EDR</option>
603
+ <option value="cdr">CDR</option>
604
+ </select>
605
+ <label class="lbl" for="set-dl-dest">To</label>
606
+ <input id="set-dl-dest" type="text" class="mono" spellcheck="false" data-admin>
607
+ <span class="push"><button id="set-dl-btn" type="button" data-admin>Download</button></span>
608
+ </div>
609
+ <div class="tablebox" id="set-downloads"></div>
610
+ <div class="path" id="set-cache-dir" style="margin-top: 6px"></div>
611
+ <div class="tablebox" id="set-cache"></div>
612
+ </fieldset>
613
+
614
+ <fieldset>
615
+ <legend>Environment</legend>
616
+ <div class="tablebox" id="set-env" style="max-height: none"></div>
617
+ </fieldset>
618
+
619
+ <fieldset>
620
+ <legend>Runs</legend>
621
+ <div class="path" id="set-runs-dir"></div>
622
+ <div class="tablebox" id="set-runs"></div>
623
+ </fieldset>
624
+ </div>
625
+ </div>
626
+ </section>
627
+ </div>
628
+ <div class="splitter rows" id="split-rows" role="separator" aria-orientation="horizontal" title="Drag to resize"></div>
629
+ <div class="lower" id="lower">
630
+ <section class="pane">
631
+ <div class="panehead">
632
+ <span class="title">Results</span>
633
+ <span class="right" id="ode-nav" data-tab="ode" hidden></span>
634
+ </div>
635
+ <div class="panebody" id="index-results" data-tab="index" data-empty="No results"></div>
636
+ <div class="panebody" id="ode-results" data-tab="ode" data-empty="No results" hidden></div>
637
+ </section>
638
+ <div class="splitter cols" id="split-cols" role="separator" aria-orientation="vertical" title="Drag to resize"></div>
639
+ <section class="pane">
640
+ <div class="panehead">
641
+ <span class="title">Details</span>
642
+ <span class="right"><button class="tool" id="detail-clear" type="button">Clear</button></span>
643
+ </div>
644
+ <div class="panebody" id="index-detail" data-tab="index" data-empty="Click a row or footprint"></div>
645
+ <div class="panebody" id="ode-detail" data-tab="ode" data-empty="Click a row or footprint" hidden></div>
646
+ </section>
647
+ </div>
648
+ </main>
649
+ </div>
650
+
651
+ <footer class="statusbar">
652
+ <span class="status" id="index-status" data-tab="index">Initializing DuckDB...</span>
653
+ <span class="status" id="ode-status" data-tab="ode" hidden>Ready</span>
654
+ <span class="cell" id="index-count" data-tab="index">0 rows</span>
655
+ <span class="cell" id="ode-count" data-tab="ode" hidden>0 rows</span>
656
+ <span class="cell" id="view-zoom" hidden></span>
657
+ <span class="cell" id="jobs-summary" title="Open the Settings tab" hidden></span>
658
+ <span class="cell" id="cursor"></span>
659
+ </footer>
660
+ </div>
661
+
662
+ <script type="module">
663
+ import * as duckdb from 'https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.29.0/+esm';
664
+
665
+ // Mirrors lunar_toolkit.lroc.nac.METADATA_LABELS (PDS_URL is derived below);
666
+ // tests/test_static.py keeps the two in sync.
667
+ const LABELS = [
668
+ ['PRODUCT_ID', 'Product'],
669
+ ['DATA_SET_ID', 'PDS dataset name'],
670
+ ['VOLUME_ID', 'PDS volume name'],
671
+ ['INSTRUMENT_HOST_ID', 'Instrument host'],
672
+ ['INSTRUMENT_ID', 'Instrument'],
673
+ ['ORIGINAL_PRODUCT_ID', 'Original product'],
674
+ ['PRODUCT_VERSION_ID', 'Product version'],
675
+ ['MISSION_PHASE_NAME', 'Mission phase name'],
676
+ ['RATIONALE_DESC', 'Rationale desc'],
677
+ ['DATA_QUALITY_ID', 'Data quality'],
678
+ ['NAC_PREROLL_START_TIME', 'NAC preroll start time'],
679
+ ['START_TIME', 'Start time'],
680
+ ['STOP_TIME', 'Stop time'],
681
+ ['SPACECRAFT_CLOCK_PARTITION', 'SC clock partition'],
682
+ ['NAC_SPACECRAFT_CLOCK_PREROLL_COUNT', 'NAC SC clock preroll count'],
683
+ ['SPACECRAFT_CLOCK_START_COUNT', 'SC clock start count'],
684
+ ['SPACECRAFT_CLOCK_STOP_COUNT', 'SC clock stop count'],
685
+ ['TARGET_NAME', 'Target name'],
686
+ ['ORBIT_NUMBER', 'Orbit number'],
687
+ ['SLEW_ANGLE', 'Slew angle'],
688
+ ['ORBIT_NODE', 'LRO node crossing'],
689
+ ['LRO_FLIGHT_DIRECTION', 'LRO flight direction'],
690
+ ['NAC_LINE_EXPOSURE_DURATION', 'NAC line exposure duration'],
691
+ ['NAC_FRAME_ID', 'NAC frame'],
692
+ ['NAC_DAC_RESET', 'NAC DAC reset'],
693
+ ['NAC_CHANNEL_A_OFFSET', 'NAC channel A offset'],
694
+ ['NAC_CHANNEL_B_OFFSET', 'NAC channel B offset'],
695
+ ['INSTRUMENT_MODE_CODE', 'Instrument mode code'],
696
+ ['COMPAND_SELECT_CODE', 'Compand select code'],
697
+ ['MODE_COMPRESSION', 'Mode compression'],
698
+ ['MODE_TEST', 'Mode test'],
699
+ ['NAC_TEMPERATURE_SCS', 'NAC temperature SCS'],
700
+ ['NAC_TEMPERATURE_FPA', 'NAC temperature FPA'],
701
+ ['NAC_TEMPERATURE_FPGA', 'NAC temperature FPGA'],
702
+ ['NAC_TEMPERATURE_TELESCOPE', 'NAC temperature telescope'],
703
+ ['IMAGE_LINES', 'Image lines'],
704
+ ['LINE_SAMPLES', 'Line samples'],
705
+ ['SAMPLE_BITS', 'Sample bits'],
706
+ ['SCALED_PIXEL_WIDTH', 'Scaled pixel width'],
707
+ ['SCALED_PIXEL_HEIGHT', 'Scaled pixel height'],
708
+ ['RESOLUTION', 'Resolution'],
709
+ ['EMISSION_ANGLE', 'Emission angle'],
710
+ ['INCIDENCE_ANGLE', 'Incidence angle'],
711
+ ['PHASE_ANGLE', 'Phase angle'],
712
+ ['NORTH_AZIMUTH', 'North azimuth'],
713
+ ['SUB_SOLAR_AZIMUTH', 'Sub solar azimuth'],
714
+ ['SUB_SOLAR_LATITUDE', 'Sub solar latitude'],
715
+ ['SUB_SOLAR_LONGITUDE', 'Sub solar longitude'],
716
+ ['SUB_SPACECRAFT_LATITUDE', 'Sub spacecraft latitude'],
717
+ ['SUB_SPACECRAFT_LONGITUDE', 'Sub spacecraft longitude'],
718
+ ['SOLAR_DISTANCE', 'Solar distance'],
719
+ ['SOLAR_LONGITUDE', 'Solar longitude'],
720
+ ['CENTER_LATITUDE', 'Center latitude'],
721
+ ['CENTER_LONGITUDE', 'Center longitude'],
722
+ ['UPPER_RIGHT_LATITUDE', 'Upper right latitude'],
723
+ ['UPPER_RIGHT_LONGITUDE', 'Upper right longitude'],
724
+ ['LOWER_RIGHT_LATITUDE', 'Lower right latitude'],
725
+ ['LOWER_RIGHT_LONGITUDE', 'Lower right longitude'],
726
+ ['LOWER_LEFT_LATITUDE', 'Lower left latitude'],
727
+ ['LOWER_LEFT_LONGITUDE', 'Lower left longitude'],
728
+ ['UPPER_LEFT_LATITUDE', 'Upper left latitude'],
729
+ ['UPPER_LEFT_LONGITUDE', 'Upper left longitude'],
730
+ ['SPACECRAFT_ALTITUDE', 'Spacecraft altitude'],
731
+ ['TARGET_CENTER_DISTANCE', 'Target center distance'],
732
+ ];
733
+
734
+ // Mirrors lunar_toolkit.ode.INSTRUMENTS (checked by tests/test_static.py).
735
+ const ODE_INSTRUMENTS = {
736
+ shadowcam: { label: 'ShadowCam', ihid: 'KPLO', iid: 'ShadowCam', default: 'cal',
737
+ types: { raw: 'RAW', cal: 'CAL', cmos: 'CMOS', umos: 'UMOS', dtm: 'DTM' } },
738
+ lroc: { label: 'LROC NAC', ihid: 'LRO', iid: 'LROC', default: 'edr',
739
+ types: { edr: 'EDRNAC4', cdr: 'CDRNAC4' } },
740
+ kaguya_tc: { label: 'Kaguya TC', ihid: 'SLN', iid: 'TC', default: 'stereo',
741
+ types: { stereo: 'TCWL2B', mono: 'TCSL2B', dtm: 'TCDTMO', ortho: 'TCORT',
742
+ morning: 'TCMORM', evening: 'TCEVEM' } },
743
+ kaguya_mi: { label: 'Kaguya MI', ihid: 'SLN', iid: 'MI', default: 'vis',
744
+ types: { vis: 'VIS2B', nir: 'NIR2B', vis2c: 'VIS2C', nir2c: 'NIR2C',
745
+ l3c5: 'MI3C', map: 'MIMAP' } },
746
+ };
747
+
748
+ const ODE_URL = 'https://oderest.rsl.wustl.edu/live2/';
749
+ const PDS_BASE = 'https://pds.mcp.nasa.gov/data/store/img/lunar_reconnaissance_orbiter/pds4/lroc/';
750
+ const LEGACY_PDS_BASE = 'https://pds.lroc.im-ldi.com/data/';
751
+ const DATASETS = ['edr', 'cdr', 'rdr'];
752
+ const NAC_SQL = "(PRODUCT_ID LIKE '%LE' OR PRODUCT_ID LIKE '%RE' OR PRODUCT_ID LIKE '%LC' OR PRODUCT_ID LIKE '%RC')";
753
+
754
+ // Mirror lunar_toolkit.constants and lroc.cumindex._MACROS (checked by tests/test_static.py).
755
+ const MOON_RADIUS_KM = 1737.4;
756
+ const POLAR_LAT_THRESHOLD = 60.0;
757
+ const FOOTPRINT_CORNERS = ['UPPER_LEFT', 'UPPER_RIGHT', 'LOWER_RIGHT', 'LOWER_LEFT'];
758
+ const MACROS = [
759
+ 'CREATE OR REPLACE MACRO _lon360(x) AS ((x % 360.0) + 360.0) % 360.0',
760
+ 'CREATE OR REPLACE MACRO _arc_overlap(w1, s1, w2, s2) AS (s1 >= 360 OR s2 >= 360 OR _lon360(w2 - w1) <= s1 OR _lon360(w1 - w2) <= s2)',
761
+ ];
762
+ const AOI_PAGE = 5000;
763
+ const ODE_AOI_PAGES = 10;
764
+
765
+ const $ = (id) => document.getElementById(id);
766
+ let db, conn;
767
+ const sources = {}; // dataset -> { path, label }
768
+ const columnCache = {};
769
+
770
+ function el(tag, attrs = {}, ...children) {
771
+ const node = document.createElement(tag);
772
+ for (const [k, v] of Object.entries(attrs)) {
773
+ if (k === 'class') node.className = v;
774
+ else if (k === 'onclick') node.addEventListener('click', v);
775
+ else node.setAttribute(k, v);
776
+ }
777
+ for (const c of children) node.append(c instanceof Node ? c : String(c ?? ''));
778
+ return node;
779
+ }
780
+
781
+ function link(url, text) {
782
+ return el('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, text ?? url);
783
+ }
784
+
785
+ function setStatus(id, msg, isError = false) {
786
+ $(id).textContent = msg;
787
+ $(id).className = 'status' + (isError ? ' error' : '');
788
+ }
789
+
790
+ function fmt(val, digits) {
791
+ if (val == null) return '';
792
+ if (val instanceof Date) return val.toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
793
+ if (typeof val === 'bigint') return String(val);
794
+ if (typeof val === 'number' && digits != null) return Number.isFinite(val) ? val.toFixed(digits) : '';
795
+ const s = String(val).trim();
796
+ return s === 'null' || s === 'undefined' || s === 'NaN' ? '' : s;
797
+ }
798
+
799
+ // DuckDB-WASM returns TIMESTAMP columns as epoch milliseconds.
800
+ function cell(row, col, digits) {
801
+ const v = row[col];
802
+ return fmt(typeof v === 'number' && /TIME$/.test(col) ? new Date(v) : v, digits);
803
+ }
804
+
805
+ function num(id) {
806
+ const v = $(id).value.trim();
807
+ return v === '' ? null : Number(v);
808
+ }
809
+
810
+ function sqlStr(s) { return `'${String(s).replace(/'/g, "''")}'`; }
811
+
812
+ function setCount(p, n) {
813
+ $(`${p}-count`).textContent = `${n} row${n === 1 ? '' : 's'}`;
814
+ }
815
+
816
+ // Tabs: switch the control panel and every [data-tab] part of the workspace / status bar
817
+ const tabs = [...document.querySelectorAll('.controls [role="tab"]')];
818
+ function selectTab(tab) {
819
+ for (const t of tabs) {
820
+ const on = t === tab;
821
+ t.setAttribute('aria-selected', String(on));
822
+ t.tabIndex = on ? 0 : -1;
823
+ }
824
+ for (const node of document.querySelectorAll('[data-tab]')) node.hidden = node.dataset.tab !== tab.dataset.for;
825
+ $('cursor').textContent = '';
826
+ }
827
+ for (const tab of tabs) {
828
+ tab.addEventListener('click', () => selectTab(tab));
829
+ tab.addEventListener('keydown', (e) => {
830
+ const d = { ArrowRight: 1, ArrowLeft: -1 }[e.key];
831
+ if (!d) return;
832
+ const next = tabs[(tabs.indexOf(tab) + d + tabs.length) % tabs.length];
833
+ selectTab(next);
834
+ next.focus();
835
+ });
836
+ }
837
+ const activeTab = () => tabs.find((t) => t.getAttribute('aria-selected') === 'true').dataset.for;
838
+
839
+ // Splitters: map | lower pane (rows), results | details (columns)
840
+ function splitter(handle, onmove) {
841
+ handle.addEventListener('pointerdown', (e) => {
842
+ e.preventDefault();
843
+ handle.setPointerCapture(e.pointerId);
844
+ const move = (ev) => onmove(ev);
845
+ const up = () => { handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', up); };
846
+ handle.addEventListener('pointermove', move);
847
+ handle.addEventListener('pointerup', up);
848
+ });
849
+ }
850
+ splitter($('split-rows'), (e) => {
851
+ const r = $('workspace').getBoundingClientRect();
852
+ const h = Math.min(Math.max(e.clientY - r.top - 6, 140), r.height - 120);
853
+ $('workspace').style.setProperty('--map-h', `${h}px`);
854
+ });
855
+ splitter($('split-cols'), (e) => {
856
+ const r = $('lower').getBoundingClientRect();
857
+ const w = Math.min(Math.max(r.right - e.clientX - 3, 200), r.width - 200);
858
+ $('lower').style.setProperty('--det-w', `${w}px`);
859
+ });
860
+ $('detail-clear').addEventListener('click', () => $(`${activeTab()}-detail`).replaceChildren());
861
+
862
+ // Geometry: ports of lunar_toolkit.geometry (query_region, aoi_region, aoi_mask), so the
863
+ // browser matches `lunar search` row for row.
864
+ const RAD = Math.PI / 180, DEG = 180 / Math.PI;
865
+ const mod = (a, b) => { const r = a % b; return r !== 0 && (r < 0) !== (b < 0) ? r + b : r; };
866
+ const wrap180 = (d) => mod(d + 180, 360) - 180;
867
+ const minOf = (xs) => xs.reduce((a, b) => (b < a ? b : a), Infinity);
868
+ const maxOf = (xs) => xs.reduce((a, b) => (b > a ? b : a), -Infinity);
869
+ const sumOf = (xs) => xs.reduce((a, b) => a + b, 0);
870
+ // Python float repr for whole numbers, so the SQL reads like region_sql's
871
+ const lit = (x) => (Number.isInteger(x) && Math.abs(x) < 1e16 ? x.toFixed(1) : String(x));
872
+
873
+ function psXY(lat, lon, hemi) {
874
+ const sgn = hemi === 'S' ? 1 : -1;
875
+ const rho = 2 * MOON_RADIUS_KM * 1000 * Math.tan(Math.PI / 4 + sgn * (lat * RAD) / 2);
876
+ return [rho * Math.sin(lon * RAD), sgn * rho * Math.cos(lon * RAD)];
877
+ }
878
+
879
+ const psHemis = (s, n) => [['S', s < -POLAR_LAT_THRESHOLD], ['N', n > POLAR_LAT_THRESHOLD]].filter(([, p]) => p).map(([h]) => h);
880
+
881
+ function queryRegion({ lat = null, lon = null, point = null, radiusKm = 0 }) {
882
+ if (point) {
883
+ const [plat, plon] = point;
884
+ const delta = (radiusKm / MOON_RADIUS_KM) * DEG;
885
+ let s = plat - delta, n = plat + delta, west, span;
886
+ if (n >= 90 || s <= -90 || (radiusKm <= 0 && Math.abs(plat) >= 90)) {
887
+ [west, span] = [0, 360];
888
+ } else {
889
+ const ratio = Math.sin(delta * RAD) / Math.cos(plat * RAD);
890
+ const dlon = ratio >= 1 ? 90 : Math.asin(ratio) * DEG;
891
+ [west, span] = [mod(plon - dlon, 360), Math.min(2 * dlon, 360)];
892
+ }
893
+ [s, n] = [Math.max(s, -90), Math.min(n, 90)];
894
+ const ps = {};
895
+ for (const hemi of psHemis(s, n)) {
896
+ const [x, y] = psXY(plat, plon, hemi);
897
+ // PS scale k = 2 / (1 + sin|lat|); use the largest k in the circle
898
+ const edge = s * n > 0 ? Math.min(Math.abs(s), Math.abs(n)) : 0;
899
+ const k = 2 / (1 + Math.sin(edge * RAD));
900
+ const r = radiusKm * 1000 * k * 1.01 + 1;
901
+ ps[hemi] = [x - r, x + r, y - r, y + r];
902
+ }
903
+ return { lat: [s, n], west, span, ps };
904
+ }
905
+ let [s, n] = lat ?? [-90, 90];
906
+ if (s > n) [s, n] = [n, s];
907
+ let west, span;
908
+ if (!lon || lon[1] - lon[0] >= 360) [west, span] = [0, 360];
909
+ else [west, span] = [mod(lon[0], 360), mod(lon[1] - lon[0], 360)];
910
+ const ps = {};
911
+ for (const hemi of psHemis(s, n)) ps[hemi] = psBboxOfBox(s, n, west, span, hemi);
912
+ return { lat: [s, n], west, span, ps };
913
+ }
914
+
915
+ function psBboxOfBox(s, n, west, span, hemi, samples = 181) {
916
+ const [s2, n2] = hemi === 'S' ? [s, Math.min(n, 0)] : [Math.max(s, 0), n];
917
+ const xs = [], ys = [];
918
+ const add = (la, lo) => { const [x, y] = psXY(la, lo, hemi); xs.push(x); ys.push(y); };
919
+ for (let i = 0; i < samples; i++) {
920
+ const f = i / (samples - 1);
921
+ const lo = west + span * f, la = s2 + (n2 - s2) * f;
922
+ add(s2, lo); add(n2, lo); add(la, west); add(la, west + span);
923
+ }
924
+ const pole = hemi === 'S' ? -90 : 90;
925
+ if (span >= 360 && s2 <= pole && pole <= n2) { xs.push(0); ys.push(0); }
926
+ const [x0, x1, y0, y1] = [minOf(xs), maxOf(xs), minOf(ys), maxOf(ys)];
927
+ const pad = 0.005 * Math.max(x1 - x0, y1 - y0, 1);
928
+ return [x0 - pad, x1 + pad, y0 - pad, y1 + pad];
929
+ }
930
+
931
+ function regionBbox(region) {
932
+ const [s, n] = region.lat;
933
+ if (region.span >= 360) return [0, s, 360, n];
934
+ return [region.west, s, mod(region.west + region.span, 360), n];
935
+ }
936
+
937
+ function polarHemi(region) {
938
+ const [s, n] = region.lat;
939
+ return n < -POLAR_LAT_THRESHOLD ? 'S' : s > POLAR_LAT_THRESHOLD ? 'N' : null;
940
+ }
941
+
942
+ // cumindex.region_sql, using the same _arc_overlap macro
943
+ function regionSql(region) {
944
+ const [s, n] = region.lat;
945
+ const conds = [`FP_MAX_LAT >= ${lit(s)}`, `FP_MIN_LAT <= ${lit(n)}`];
946
+ if (region.span < 360) conds.push(`_arc_overlap(FP_LON_WEST, FP_LON_SPAN, ${lit(region.west)}, ${lit(region.span)})`);
947
+ for (const [hemi, [x0, x1, y0, y1]] of Object.entries(region.ps)) {
948
+ conds.push(`(FP_PS_HEMI IS DISTINCT FROM '${hemi}' OR (FP_PS_XMAX >= ${lit(x0)} AND FP_PS_XMIN <= ${lit(x1)} `
949
+ + `AND FP_PS_YMAX >= ${lit(y0)} AND FP_PS_YMIN <= ${lit(y1)}))`);
950
+ }
951
+ return `(${conds.join(' AND ')})`;
952
+ }
953
+
954
+ // cumindex.footprint_wkt_sql
955
+ function footprintWktSql(names) {
956
+ const have = new Set(names);
957
+ const pairs = FOOTPRINT_CORNERS.map((c) => [`${c}_LATITUDE`, `${c}_LONGITUDE`]);
958
+ if (!pairs.every(([a, b]) => have.has(a) && have.has(b))) return 'NULL';
959
+ const pts = [...pairs, pairs[0]].map(([a, b]) =>
960
+ `CAST(round(FP_LON_WEST + _lon360(round(${b} - FP_LON_WEST, 6)), 4) AS VARCHAR) || ' ' || CAST(round(${a}, 4) AS VARCHAR)`);
961
+ return `'POLYGON((' || ${pts.join(" || ', ' || ")} || '))'`;
962
+ }
963
+
964
+ // Rings are open arrays of [lon, lat] (or [x, y] once projected); a geometry is a list
965
+ // of polygons, each [exterior, ...holes].
966
+ const openRing = (r) => (r.length > 1 && r[0][0] === r.at(-1)[0] && r[0][1] === r.at(-1)[1] ? r.slice(0, -1) : r);
967
+
968
+ function densifyRing(ring, step) {
969
+ const out = [];
970
+ ring.forEach(([lo0, la0], i) => {
971
+ const [lo1, la1] = ring[(i + 1) % ring.length];
972
+ const dlo = wrap180(lo1 - lo0), dla = la1 - la0;
973
+ const n = Math.max(1, Math.ceil(Math.max(Math.abs(dlo), Math.abs(dla)) / step));
974
+ for (let j = 0; j < n; j++) out.push([lo0 + dlo * j / n, la0 + dla * j / n]);
975
+ });
976
+ return out;
977
+ }
978
+
979
+ function unwrapRing(ring) {
980
+ const out = [ring[0]];
981
+ for (const [lo, la] of ring.slice(1)) out.push([out.at(-1)[0] + wrap180(lo - out.at(-1)[0]), la]);
982
+ return out;
983
+ }
984
+
985
+ const winding = (ring) => sumOf(ring.map((p, i) => wrap180(ring[(i + 1) % ring.length][0] - p[0])));
986
+ const enclosesPole = (ring) => Math.abs(winding(ring)) > 180;
987
+ const lonMid = (ring) => { const los = ring.map((p) => p[0]); return (minOf(los) + maxOf(los)) / 2; };
988
+
989
+ function shiftTo0360(rings) {
990
+ const k = Math.floor(minOf(rings[0].map((p) => p[0])) / 360);
991
+ return rings.map((r) => r.map(([lo, la]) => [lo - 360 * k, la]));
992
+ }
993
+
994
+ function parseWkt(text) {
995
+ const toks = text.replace(/^\s*SRID=\d+;/i, '').toUpperCase()
996
+ .match(/[A-Z]+|\(|\)|,|[-+]?(?:\d+\.?\d*|\.\d+)(?:E[-+]?\d+)?/g) ?? [];
997
+ let i = 0;
998
+ const peek = () => toks[i];
999
+ const take = (t) => { if (toks[i] !== t) throw new Error(`Bad WKT near token ${i}`); i++; };
1000
+ const isNum = (t) => t != null && /^[-+.\d]/.test(t);
1001
+ function list() {
1002
+ take('(');
1003
+ const out = [];
1004
+ for (;;) {
1005
+ if (peek() === '(') out.push(list());
1006
+ else {
1007
+ const p = [];
1008
+ while (isNum(peek())) p.push(Number(toks[i++]));
1009
+ if (p.length < 2) throw new Error('Bad WKT coordinate');
1010
+ out.push(p);
1011
+ }
1012
+ if (peek() !== ',') break;
1013
+ i++;
1014
+ }
1015
+ take(')');
1016
+ return out;
1017
+ }
1018
+ function geom() {
1019
+ const type = toks[i++];
1020
+ while (['Z', 'M', 'ZM'].includes(peek())) i++;
1021
+ if (peek() === 'EMPTY') { i++; return []; }
1022
+ if (type === 'GEOMETRYCOLLECTION') {
1023
+ take('(');
1024
+ const out = [];
1025
+ for (;;) { out.push(...geom()); if (peek() !== ',') break; i++; }
1026
+ take(')');
1027
+ return out;
1028
+ }
1029
+ const c = list();
1030
+ if (type === 'POLYGON') return [c];
1031
+ if (type === 'MULTIPOLYGON') return c;
1032
+ if (['POINT', 'LINESTRING', 'MULTIPOINT', 'MULTILINESTRING'].includes(type)) return [];
1033
+ throw new Error(`Unsupported WKT type ${type}`);
1034
+ }
1035
+ const polys = geom();
1036
+ if (i !== toks.length) throw new Error('Trailing WKT');
1037
+ return polys.map((poly) => poly.map((r) => r.map((p) => [p[0], p[1]])));
1038
+ }
1039
+
1040
+ function geojsonPolygons(obj) {
1041
+ switch (obj?.type) {
1042
+ case 'FeatureCollection': return (obj.features ?? []).flatMap(geojsonPolygons);
1043
+ case 'Feature': return obj.geometry ? geojsonPolygons(obj.geometry) : [];
1044
+ case 'GeometryCollection': return (obj.geometries ?? []).flatMap(geojsonPolygons);
1045
+ case 'Polygon': return obj.coordinates.length ? [obj.coordinates] : [];
1046
+ case 'MultiPolygon': return obj.coordinates.filter((p) => p.length);
1047
+ case 'Point': case 'MultiPoint': case 'LineString': case 'MultiLineString': return [];
1048
+ default: throw new Error(`Unsupported GeoJSON type ${obj?.type}`);
1049
+ }
1050
+ }
1051
+
1052
+ // geometry.load_aoi: densified (1 deg), unwrapped, exterior west edge in [0, 360)
1053
+ function loadAoi(text) {
1054
+ text = text.trim();
1055
+ if (!text) throw new Error('Load or paste an AOI polygon (GeoJSON or WKT) first.');
1056
+ let polys;
1057
+ if (text.startsWith('{')) polys = geojsonPolygons(JSON.parse(text));
1058
+ else {
1059
+ try { polys = parseWkt(text); } catch { throw new Error(`Not GeoJSON or WKT: ${text.slice(0, 80)}`); }
1060
+ }
1061
+ const polygons = [];
1062
+ for (const poly of polys) {
1063
+ let rings = poly.map((r) => openRing(r.map((p) => [Number(p[0]), Number(p[1])]))).filter((r) => r.length >= 3);
1064
+ if (!rings.length) continue;
1065
+ rings = rings.map((r) => unwrapRing(densifyRing(r, 1)));
1066
+ const out = shiftTo0360(rings.slice(0, 1));
1067
+ const extMid = lonMid(out[0]);
1068
+ for (const h of rings.slice(1)) {
1069
+ const kh = Math.round((lonMid(h) - extMid) / 360);
1070
+ out.push(h.map(([lo, la]) => [lo - 360 * kh, la]));
1071
+ }
1072
+ polygons.push(out);
1073
+ }
1074
+ if (!polygons.length) throw new Error('AOI contains no Polygon or MultiPolygon geometry');
1075
+ if (polygons.flat(2).some(([, la]) => !(Math.abs(la) <= 90))) {
1076
+ throw new Error('AOI latitude outside +/-90 deg - coordinates must be (lon, lat)');
1077
+ }
1078
+ return { polygons };
1079
+ }
1080
+
1081
+ function coveringArc(arcs) {
1082
+ const pieces = [];
1083
+ for (let [w, sp] of arcs) {
1084
+ if (sp >= 360) return [0, 360];
1085
+ w = mod(w, 360);
1086
+ if (w + sp > 360) pieces.push([w, 360], [0, w + sp - 360]);
1087
+ else pieces.push([w, w + sp]);
1088
+ }
1089
+ pieces.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
1090
+ const merged = [[...pieces[0]]];
1091
+ for (const [a, b] of pieces.slice(1)) {
1092
+ const last = merged.at(-1);
1093
+ if (a <= last[1]) last[1] = Math.max(last[1], b);
1094
+ else merged.push([a, b]);
1095
+ }
1096
+ const gaps = merged.slice(0, -1).map((m, i) => [merged[i + 1][0] - m[1], merged[i + 1][0]]);
1097
+ gaps.push([merged[0][0] + 360 - merged.at(-1)[1], merged[0][0]]);
1098
+ const [size, eastOfGap] = gaps.reduce((b, g) => (g[0] > b[0] || (g[0] === b[0] && g[1] > b[1]) ? g : b));
1099
+ return size <= 0 ? [0, 360] : [mod(eastOfGap, 360), 360 - size];
1100
+ }
1101
+
1102
+ // geometry.aoi_region
1103
+ function aoiRegion(aoi) {
1104
+ const exts = aoi.polygons.map((p) => p[0]);
1105
+ const lats = exts.flat().map((p) => p[1]);
1106
+ let s = minOf(lats), n = maxOf(lats);
1107
+ const arcs = [];
1108
+ for (const ext of exts) {
1109
+ if (enclosesPole(ext)) {
1110
+ if (sumOf(ext.map((p) => p[1])) < 0) s = -90; else n = 90;
1111
+ arcs.push([0, 360]);
1112
+ } else {
1113
+ const los = ext.map((p) => p[0]);
1114
+ arcs.push([minOf(los), maxOf(los) - minOf(los)]);
1115
+ }
1116
+ }
1117
+ const [west, span] = coveringArc(arcs);
1118
+ const ps = {};
1119
+ for (const hemi of psHemis(s, n)) {
1120
+ if (!lats.every((la) => (hemi === 'S' ? la <= 0 : la >= 0))) {
1121
+ ps[hemi] = psBboxOfBox(s, n, west, span, hemi);
1122
+ continue;
1123
+ }
1124
+ const xy = exts.flat().map(([lo, la]) => psXY(la, lo, hemi));
1125
+ const xs = xy.map((p) => p[0]), ys = xy.map((p) => p[1]);
1126
+ const [x0, x1, y0, y1] = [minOf(xs), maxOf(xs), minOf(ys), maxOf(ys)];
1127
+ const pad = 0.005 * Math.max(x1 - x0, y1 - y0) + 1;
1128
+ ps[hemi] = [x0 - pad, x1 + pad, y0 - pad, y1 + pad];
1129
+ }
1130
+ return { lat: [s, n], west, span, ps };
1131
+ }
1132
+
1133
+ // Clamp away from the opposite pole, where the projection diverges
1134
+ const psRing = (ring, hemi) => ring.map(([lo, la]) => psXY(hemi === 'S' ? Math.min(la, 89) : Math.max(la, -89), lo, hemi));
1135
+
1136
+ function llRing(ring) {
1137
+ let r = unwrapRing(ring);
1138
+ if (enclosesPole(ring)) {
1139
+ const pole = sumOf(r.map((p) => p[1])) < 0 ? -90 : 90;
1140
+ const loEnd = r[0][0] + winding(ring);
1141
+ r = [...r, [loEnd, r[0][1]], [loEnd, pole], [r[0][0], pole]];
1142
+ }
1143
+ return r;
1144
+ }
1145
+
1146
+ const polygon = (rings) => [rings[0], ...rings.slice(1).filter((h) => h.length >= 3)];
1147
+ const shiftX = (geom, dx) => geom.map((poly) => poly.map((r) => r.map(([x, y]) => [x + dx, y])));
1148
+
1149
+ function aoiGeometry(aoi, hemi, replicate = true) {
1150
+ if (hemi) return aoi.polygons.map((poly) => polygon(poly.map((r) => psRing(r, hemi))));
1151
+ const g = aoi.polygons.map((poly) => polygon(shiftTo0360(poly.map(llRing))));
1152
+ return replicate ? [...g, ...shiftX(g, 360), ...shiftX(g, -360)] : g;
1153
+ }
1154
+
1155
+ function footprintGeometry(ringsList, hemi) {
1156
+ const parts = [];
1157
+ for (let rings of ringsList) {
1158
+ rings = rings.map(openRing);
1159
+ if (!rings.length || !rings[0].length) continue;
1160
+ parts.push(polygon(hemi ? rings.map((r) => psRing(r, hemi))
1161
+ : shiftTo0360(rings.map((r) => (r.length >= 3 ? llRing(r) : r)))));
1162
+ }
1163
+ return parts.length ? parts : null;
1164
+ }
1165
+
1166
+ function boxRing(s, n, west, span, step = 1) {
1167
+ const m = Math.max(1, Math.ceil(span / step)), k = Math.max(1, Math.ceil((n - s) / step));
1168
+ const out = [];
1169
+ for (let i = 0; i <= m; i++) out.push([west + span * i / m, s]);
1170
+ for (let j = 1; j <= k; j++) out.push([west + span, s + (n - s) * j / k]);
1171
+ for (let i = m - 1; i >= 0; i--) out.push([west + span * i / m, n]);
1172
+ for (let j = 1; j < k; j++) out.push([west, n - (n - s) * j / k]);
1173
+ return out;
1174
+ }
1175
+
1176
+ function numOrNull(v) {
1177
+ if (v == null || (typeof v === 'string' && !v.trim())) return null;
1178
+ const f = Number(v);
1179
+ return Number.isNaN(f) ? null : f;
1180
+ }
1181
+
1182
+ // geometry._row_footprint: WKT column, else the four corners, else the bbox columns
1183
+ function rowFootprint(row, wktCol, hemi) {
1184
+ const wkt = wktCol ? row[wktCol] : null;
1185
+ if (typeof wkt === 'string' && wkt.trim()) {
1186
+ try { return footprintGeometry(parseWkt(wkt), hemi); } catch { /* fall back to corners / bbox */ }
1187
+ }
1188
+ const corners = FOOTPRINT_CORNERS.map((c) => [numOrNull(row[`${c}_LONGITUDE`]), numOrNull(row[`${c}_LATITUDE`])]);
1189
+ if (corners.every(([lo, la]) => lo != null && la != null)) return footprintGeometry([[corners]], hemi);
1190
+ let [s, n, west, span] = ['FP_MIN_LAT', 'FP_MAX_LAT', 'FP_LON_WEST', 'FP_LON_SPAN'].map((c) => numOrNull(row[c]));
1191
+ if ([s, n, west, span].includes(null)) {
1192
+ let east;
1193
+ [s, n, west, east] = ['min_lat', 'max_lat', 'lon_west', 'lon_east'].map((c) => numOrNull(row[c]));
1194
+ if ([s, n, west, east].includes(null)) return null;
1195
+ span = Math.min(east >= west ? east - west : east - west + 360, 360);
1196
+ }
1197
+ const ring = boxRing(s, n, west, span);
1198
+ return [[hemi ? psRing(ring, hemi) : ring]];
1199
+ }
1200
+
1201
+ function bboxOf(rings) {
1202
+ const b = [Infinity, -Infinity, Infinity, -Infinity];
1203
+ for (const r of rings) for (const [x, y] of r) {
1204
+ if (x < b[0]) b[0] = x; if (x > b[1]) b[1] = x;
1205
+ if (y < b[2]) b[2] = y; if (y > b[3]) b[3] = y;
1206
+ }
1207
+ return b;
1208
+ }
1209
+
1210
+ const disjoint = (a, b) => a[0] > b[1] || b[0] > a[1] || a[2] > b[3] || b[2] > a[3];
1211
+
1212
+ function segHit(a, b, c, d) {
1213
+ const o = (p, q, r) => Math.sign((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]));
1214
+ const on = (p, q, r) => Math.min(p[0], q[0]) <= r[0] && r[0] <= Math.max(p[0], q[0])
1215
+ && Math.min(p[1], q[1]) <= r[1] && r[1] <= Math.max(p[1], q[1]);
1216
+ const o1 = o(a, b, c), o2 = o(a, b, d), o3 = o(c, d, a), o4 = o(c, d, b);
1217
+ if (o1 !== o2 && o3 !== o4) return true;
1218
+ return (o1 === 0 && on(a, b, c)) || (o2 === 0 && on(a, b, d)) || (o3 === 0 && on(c, d, a)) || (o4 === 0 && on(c, d, b));
1219
+ }
1220
+
1221
+ function ringsCross(r, s) {
1222
+ const bs = bboxOf([s]);
1223
+ for (let i = 0; i < r.length; i++) {
1224
+ const a = r[i], b = r[(i + 1) % r.length];
1225
+ if (disjoint(bboxOf([[a, b]]), bs)) continue;
1226
+ for (let j = 0; j < s.length; j++) if (segHit(a, b, s[j], s[(j + 1) % s.length])) return true;
1227
+ }
1228
+ return false;
1229
+ }
1230
+
1231
+ // Even-odd over all rings, so holes count as outside
1232
+ function inside([x, y], rings) {
1233
+ let c = false;
1234
+ for (const r of rings) {
1235
+ for (let i = 0, j = r.length - 1; i < r.length; j = i++) {
1236
+ const [xi, yi] = r[i], [xj, yj] = r[j];
1237
+ if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) c = !c;
1238
+ }
1239
+ }
1240
+ return c;
1241
+ }
1242
+
1243
+ function polysIntersect(P, Q) {
1244
+ if (disjoint(bboxOf(P), bboxOf(Q))) return false;
1245
+ for (const r of P) for (const s of Q) if (ringsCross(r, s)) return true;
1246
+ return inside(Q[0][0], P) || inside(P[0][0], Q);
1247
+ }
1248
+
1249
+ // geometry.aoi_mask as a row predicate; rows without any geometry are kept
1250
+ function aoiPredicate(aoi, wktCol) {
1251
+ const hemi = polarHemi(aoiRegion(aoi));
1252
+ const target = aoiGeometry(aoi, hemi);
1253
+ const boxes = target.map(bboxOf);
1254
+ return (row) => {
1255
+ const g = rowFootprint(row, wktCol, hemi);
1256
+ if (!g) return true;
1257
+ return g.some((P) => { const bp = bboxOf(P); return target.some((Q, i) => !disjoint(bp, boxes[i]) && polysIntersect(P, Q)); });
1258
+ };
1259
+ }
1260
+
1261
+ function circleRing(lat, lon, radiusKm, n = 120) {
1262
+ const d = radiusKm / MOON_RADIUS_KM, p = lat * RAD;
1263
+ if (d <= 0) return [[lon, lat]];
1264
+ const out = [];
1265
+ for (let i = 0; i < n; i++) {
1266
+ const t = (2 * Math.PI * i) / n;
1267
+ const p2 = Math.asin(Math.sin(p) * Math.cos(d) + Math.cos(p) * Math.sin(d) * Math.cos(t));
1268
+ const l2 = lon * RAD + Math.atan2(Math.sin(t) * Math.sin(d) * Math.cos(p), Math.cos(d) - Math.sin(p) * Math.sin(p2));
1269
+ out.push([l2 * DEG, p2 * DEG]);
1270
+ }
1271
+ return out;
1272
+ }
1273
+
1274
+ // Search region from a form (prefix 'ix' or 'ode'), plus how to draw it
1275
+ function readRegion(p) {
1276
+ const mode = $(`${p}-mode`).value;
1277
+ const finite = (...vs) => vs.every((v) => v != null && Number.isFinite(v));
1278
+ if (mode === 'aoi') {
1279
+ const aoi = loadAoi($(`${p}-aoi`).value);
1280
+ return { aoi, region: aoiRegion(aoi), shape: (hemi) => aoiGeometry(aoi, hemi, false) };
1281
+ }
1282
+ if (mode === 'point') {
1283
+ const [lat, lon, r] = [`${p}-lat`, `${p}-lon`, `${p}-radius`].map(num);
1284
+ if (!finite(lat, lon) || Math.abs(lat) > 90) throw new Error('Enter a point latitude (-90..90) and longitude.');
1285
+ const radiusKm = finite(r) ? Math.max(r, 0) : 0;
1286
+ const ring = circleRing(lat, lon, radiusKm);
1287
+ return {
1288
+ region: queryRegion({ point: [lat, lon], radiusKm }),
1289
+ shape: (hemi) => [[hemi ? psRing(ring, hemi) : ring.length > 2 ? llRing(ring) : ring]],
1290
+ };
1291
+ }
1292
+ const [west, south, east, north] = [`${p}-west`, `${p}-south`, `${p}-east`, `${p}-north`].map(num);
1293
+ if (!finite(west, south, east, north)) throw new Error('Fill in the box.');
1294
+ const region = queryRegion({ lat: [south, north], lon: [west, east] });
1295
+ return {
1296
+ region,
1297
+ shape: (hemi) => {
1298
+ const [s, n] = region.lat;
1299
+ if (hemi && region.span >= 360) {
1300
+ // A full-longitude box is a disc or annulus around the pole
1301
+ const circle = (la) => psRing(Array.from({ length: 360 }, (_, i) => [i, la]), hemi);
1302
+ const [outer, inner] = hemi === 'S' ? [n, s] : [s, n];
1303
+ return [Math.abs(inner) >= 90 ? [circle(outer)] : [circle(outer), circle(inner)]];
1304
+ }
1305
+ const ring = boxRing(s, n, region.west, region.span);
1306
+ return [[hemi ? psRing(ring, hemi) : ring]];
1307
+ },
1308
+ };
1309
+ }
1310
+
1311
+ for (const p of ['ix', 'ode']) {
1312
+ const sync = () => {
1313
+ for (const node of $(`${p}-mode`).closest('fieldset').querySelectorAll('[data-mode]')) {
1314
+ node.hidden = node.dataset.mode !== $(`${p}-mode`).value;
1315
+ }
1316
+ };
1317
+ $(`${p}-mode`).addEventListener('change', sync);
1318
+ sync();
1319
+ $(`${p}-aoi-file`).addEventListener('change', async (e) => {
1320
+ if (e.target.files[0]) $(`${p}-aoi`).value = await e.target.files[0].text();
1321
+ });
1322
+ }
1323
+
1324
+ // Footprint map (inline SVG): lon/lat, or polar stereographic when the search region (or
1325
+ // every footprint) lies poleward of +/-60 deg. Wheel zooms, drag pans, double click resets.
1326
+ function niceStep(span, target = 6) {
1327
+ const raw = span / target, p = 10 ** Math.floor(Math.log10(raw));
1328
+ if (raw > 10) return [15, 30, 45, 90, 180].find((s) => s >= raw) ?? 180;
1329
+ return [1, 2, 5, 10].map((m) => m * p).find((s) => s >= raw) ?? 10 * p;
1330
+ }
1331
+
1332
+ const fmtDeg = (v, step) => {
1333
+ const d = Math.max(0, -Math.floor(Math.log10(step) + 1e-9));
1334
+ return (Math.abs(v) < 1e-9 ? 0 : v).toFixed(d);
1335
+ };
1336
+
1337
+ // Round step of 1, 2 or 5 x 10^n giving about `target` intervals
1338
+ function step125(span, target = 6) {
1339
+ const raw = span / target, p = 10 ** Math.floor(Math.log10(raw));
1340
+ return [1, 2, 5, 10].map((m) => m * p).find((s) => s >= raw) ?? 10 * p;
1341
+ }
1342
+
1343
+ const MARGIN = { l: 50, r: 12, t: 10, b: 34 };
1344
+
1345
+ function renderMap(container, { hemi, region, items }) {
1346
+ container._resize?.disconnect();
1347
+ container._highlight = null;
1348
+ if (!items.length && !region) { container.replaceChildren(); return; }
1349
+ const NS = 'http://www.w3.org/2000/svg';
1350
+ const svg = document.createElementNS(NS, 'svg');
1351
+ const reset = el('button', { class: 'tool', type: 'button', title: 'Reset view (double click)' }, 'Reset view');
1352
+ const bar = el('div', { class: 'panehead' },
1353
+ el('span', { class: 'title' }, 'Footprints'),
1354
+ el('span', { class: 'info' }, hemi ? `Polar stereographic (${hemi === 'S' ? 'south' : 'north'}), R = ${MOON_RADIUS_KM} km`
1355
+ : 'Lon/lat (equirectangular)', ` | ${items.length} footprint(s) | wheel zoom, drag pan`),
1356
+ el('span', { class: 'right' }, reset));
1357
+ container.replaceChildren(el('div', { class: 'map' }, bar, svg));
1358
+ const clipId = `clip-${container.id}`;
1359
+ const M = MARGIN;
1360
+
1361
+ // Lon/lat: center everything on the region (or the footprints) to avoid the seam
1362
+ const regionGeom = region ? region.shape(hemi) : [];
1363
+ let geoms = items.map((it) => it.geom);
1364
+ let kx = 1;
1365
+ if (!hemi) {
1366
+ const mids = [...regionGeom, ...geoms.flat()].map((P) => { const b = bboxOf([P[0]]); return (b[0] + b[1]) / 2; });
1367
+ const c = region ? (region.region.span < 360 ? region.region.west + region.region.span / 2 : 180)
1368
+ : Math.atan2(sumOf(mids.map((m) => Math.sin(m * RAD))), sumOf(mids.map((m) => Math.cos(m * RAD)))) * DEG;
1369
+ const recenter = (g) => g.map((P) => { const b = bboxOf([P[0]]); return shiftX([P], -360 * Math.round(((b[0] + b[1]) / 2 - c) / 360))[0]; });
1370
+ geoms = geoms.map(recenter);
1371
+ const lats = [...regionGeom, ...geoms.flat()].flatMap((P) => P[0].map((p) => p[1]));
1372
+ kx = Math.max(0.2, Math.cos(((minOf(lats) + maxOf(lats)) / 2) * RAD));
1373
+ regionGeom.splice(0, regionGeom.length, ...recenter(regionGeom));
1374
+ }
1375
+ // Data coordinates: x right, y down
1376
+ const toXY = (g) => g.map((P) => P.map((r) => r.map(([x, y]) => (hemi ? [x, -y] : [x * kx, -y]))));
1377
+ const reg = toXY(regionGeom);
1378
+ const fps = geoms.map(toXY);
1379
+ const all = [...reg, ...fps.flat()].flat();
1380
+ let [x0, x1, y0, y1] = all.length ? bboxOf(all) : [-1, 1, -1, 1];
1381
+ const minSpan = hemi ? 2000 : 0.05;
1382
+ if (x1 - x0 < minSpan) [x0, x1] = [(x0 + x1 - minSpan) / 2, (x0 + x1 + minSpan) / 2];
1383
+ if (y1 - y0 < minSpan) [y0, y1] = [(y0 + y1 - minSpan) / 2, (y0 + y1 + minSpan) / 2];
1384
+ const home = { cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, w: x1 - x0, h: y1 - y0 };
1385
+ // SW x SH: whole svg; W x H: plot area inside the axis margins
1386
+ let view = null, SW = 0, SH = 0, W = 0, H = 0, sel = -1, touched = false;
1387
+ const fit = () => {
1388
+ SW = svg.clientWidth || 800; SH = svg.clientHeight || 420;
1389
+ W = Math.max(SW - M.l - M.r, 40); H = Math.max(SH - M.t - M.b, 40);
1390
+ view = { cx: home.cx, cy: home.cy, k: 0.92 * Math.min(W / home.w, H / home.h) };
1391
+ };
1392
+ const sx = (x) => W / 2 + (x - view.cx) * view.k, sy = (y) => H / 2 + (y - view.cy) * view.k;
1393
+ const pathD = (g) => g.map((P) => P.map((r) => (r.length < 3
1394
+ ? r.map(([x, y]) => `M${(sx(x) - 3).toFixed(1)} ${sy(y).toFixed(1)}a3 3 0 1 0 6 0a3 3 0 1 0 -6 0`).join('')
1395
+ : 'M' + r.map(([x, y]) => `${sx(x).toFixed(1)} ${sy(y).toFixed(1)}`).join('L') + 'Z')).join('')).join('');
1396
+ const esc = (s) => String(s).replace(/[&<>"]/g, (ch) => `&#${ch.charCodeAt(0)};`);
1397
+ const sgn = hemi === 'S' ? 1 : -1;
1398
+ const rho = (la) => 2 * MOON_RADIUS_KM * 1000 * Math.tan(Math.PI / 4 + sgn * (la * RAD) / 2);
1399
+ const latOf = (r) => sgn * (2 * Math.atan(r / (2 * MOON_RADIUS_KM * 1000)) - Math.PI / 2) * DEG;
1400
+
1401
+ // Grid lines (clipped), graticule labels inside the plot, ticks + tick labels on the frame
1402
+ function axes() {
1403
+ const lines = [], inner = [], ticks = [], labels = [];
1404
+ const vx0 = view.cx - W / 2 / view.k, vx1 = view.cx + W / 2 / view.k;
1405
+ const vy0 = view.cy - H / 2 / view.k, vy1 = view.cy + H / 2 / view.k;
1406
+ const xTick = (x, text) => {
1407
+ if (x < -0.5 || x > W + 0.5) return;
1408
+ ticks.push(`M${x.toFixed(1)} ${H}v4`);
1409
+ labels.push(`<text x="${x.toFixed(1)}" y="${H + 15}" text-anchor="middle">${text}</text>`);
1410
+ };
1411
+ const yTick = (y, text) => {
1412
+ if (y < -0.5 || y > H + 0.5) return;
1413
+ ticks.push(`M0 ${y.toFixed(1)}h-4`);
1414
+ labels.push(`<text x="-6" y="${(y + 3.5).toFixed(1)}" text-anchor="end">${text}</text>`);
1415
+ };
1416
+ let xTitle, yTitle;
1417
+ if (!hemi) {
1418
+ const lonStep = niceStep((vx1 - vx0) / kx), latStep = niceStep(vy1 - vy0);
1419
+ for (let lo = Math.ceil(vx0 / kx / lonStep) * lonStep; lo <= vx1 / kx; lo += lonStep) {
1420
+ const x = sx(lo * kx);
1421
+ lines.push(`M${x.toFixed(1)} 0V${H}`);
1422
+ xTick(x, fmtDeg(mod(lo, 360), lonStep));
1423
+ }
1424
+ for (let la = Math.ceil(-vy1 / latStep) * latStep; la <= Math.min(90, -vy0); la += latStep) {
1425
+ if (la < -90) continue;
1426
+ const y = sy(-la);
1427
+ lines.push(`M0 ${y.toFixed(1)}H${W}`);
1428
+ yTick(y, fmtDeg(la, latStep));
1429
+ }
1430
+ [xTitle, yTitle] = ['Longitude (deg E)', 'Latitude (deg)'];
1431
+ } else {
1432
+ const inView = (x, y) => x >= 4 && x <= W - 30 && y >= 12 && y <= H - 4;
1433
+ const cx = Math.min(Math.max(0, vx0), vx1), cy = Math.min(Math.max(0, vy0), vy1);
1434
+ const rMin = Math.hypot(cx, cy);
1435
+ const rMax = Math.max(...[[vx0, vy0], [vx0, vy1], [vx1, vy0], [vx1, vy1]].map(([x, y]) => Math.hypot(x, y)));
1436
+ const [laIn, laOut] = [latOf(rMin), latOf(rMax)];
1437
+ const latStep = niceStep(Math.abs(laOut - laIn));
1438
+ const poleward = (la) => (hemi === 'S' ? la <= 0 : la >= 0);
1439
+ const angSpan = rMin > 0 ? Math.min(360, (2 * Math.hypot(vx1 - vx0, vy1 - vy0) / rMin) * DEG) : 360;
1440
+ const lonStep = Math.min(30, Math.max(0.5, niceStep(angSpan)));
1441
+ // Latitude labels between two meridians, towards the view centre
1442
+ const dirC = (Math.hypot(view.cx, view.cy) > 1e-6 ? Math.atan2(view.cy, view.cx) : Math.PI / 4) + (lonStep / 2) * RAD;
1443
+ const lats = [];
1444
+ for (let la = Math.ceil(Math.min(laIn, laOut) / latStep) * latStep; la <= Math.max(laIn, laOut); la += latStep) {
1445
+ if (poleward(la) && Math.abs(la) < 90) lats.push(la);
1446
+ }
1447
+ for (const la of lats) {
1448
+ const r = rho(la) * view.k;
1449
+ 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`);
1450
+ const lx = sx(Math.cos(dirC) * rho(la)), ly = sy(Math.sin(dirC) * rho(la));
1451
+ if (inView(lx, ly)) inner.push(`<text class="gl" x="${(lx + 3).toFixed(1)}" y="${(ly - 3).toFixed(1)}">${fmtDeg(la, latStep)}</text>`);
1452
+ }
1453
+ const rLab = Math.max(Math.hypot(view.cx, view.cy), 0.3 * Math.min(W, H) / view.k);
1454
+ for (let lo = 0; lo < 360; lo += lonStep) {
1455
+ // Data coords (y down): S x = rho sin, y = -rho cos; N y = rho cos
1456
+ const ux = Math.sin(lo * RAD), uy = -sgn * Math.cos(lo * RAD);
1457
+ lines.push(`M${sx(ux * rMin).toFixed(1)} ${sy(uy * rMin).toFixed(1)}L${sx(ux * rMax).toFixed(1)} ${sy(uy * rMax).toFixed(1)}`);
1458
+ const lx = sx(ux * rLab), ly = sy(uy * rLab);
1459
+ if (inView(lx, ly)) inner.push(`<text class="gl" x="${(lx + 3).toFixed(1)}" y="${(ly - 3).toFixed(1)}">${fmtDeg(lo, lonStep)}E</text>`);
1460
+ }
1461
+ // Frame ticks in polar stereographic km (y up)
1462
+ const xs = step125((vx1 - vx0) / 1000, Math.max(2, W / 90)), ys = step125((vy1 - vy0) / 1000, Math.max(2, H / 50));
1463
+ for (let v = Math.ceil(vx0 / 1000 / xs) * xs; v <= vx1 / 1000; v += xs) xTick(sx(v * 1000), fmtDeg(v, xs));
1464
+ for (let v = Math.ceil(-vy1 / 1000 / ys) * ys; v <= -vy0 / 1000; v += ys) yTick(sy(-v * 1000), fmtDeg(v, ys));
1465
+ [xTitle, yTitle] = ['x (km)', 'y (km)'];
1466
+ }
1467
+ labels.push(`<text class="axis" x="${(W / 2).toFixed(1)}" y="${H + 29}" text-anchor="middle">${xTitle}</text>`,
1468
+ `<text class="axis" transform="translate(${-M.l + 12} ${(H / 2).toFixed(1)}) rotate(-90)" text-anchor="middle">${yTitle}</text>`);
1469
+ return { grid: `<path class="grid" d="${lines.join('')}"/>${inner.join('')}`, ticks: ticks.join(''), labels: labels.join('') };
1470
+ }
1471
+
1472
+ function draw() {
1473
+ if (!view) fit();
1474
+ svg.setAttribute('viewBox', `0 0 ${SW} ${SH}`);
1475
+ const a = axes();
1476
+ const order = fps.map((_, i) => i).filter((i) => i !== sel);
1477
+ if (sel >= 0) order.push(sel);
1478
+ svg.innerHTML = `<defs><clipPath id="${clipId}"><rect width="${W}" height="${H}"/></clipPath></defs>`
1479
+ + `<g transform="translate(${M.l} ${M.t})"><rect class="plot" width="${W}" height="${H}"/>`
1480
+ + `<g clip-path="url(#${clipId})">${a.grid}`
1481
+ + order.map((i) => `<path class="fp${i === sel ? ' sel' : ''}" data-i="${i}" d="${pathD(fps[i])}"><title>${esc(items[i].title)}</title></path>`).join('')
1482
+ + (reg.length ? `<path class="region" d="${pathD(reg)}"/>` : '')
1483
+ + `</g><rect class="frame" x="0.5" y="0.5" width="${W - 1}" height="${H - 1}"/>`
1484
+ + `<path class="tick" d="${a.ticks}"/>${a.labels}</g>`;
1485
+ }
1486
+
1487
+ let frame = 0;
1488
+ const redraw = () => { if (!frame) frame = requestAnimationFrame(() => { frame = 0; draw(); }); };
1489
+ // Pointer position in plot coordinates
1490
+ const plotXY = (e) => { const r = svg.getBoundingClientRect(); return [e.clientX - r.left - M.l, e.clientY - r.top - M.t]; };
1491
+ svg.addEventListener('wheel', (e) => {
1492
+ e.preventDefault();
1493
+ const [mx, my] = plotXY(e);
1494
+ const x = view.cx + (mx - W / 2) / view.k, y = view.cy + (my - H / 2) / view.k;
1495
+ touched = true;
1496
+ view.k *= Math.exp(-e.deltaY * (e.deltaMode ? 0.05 : 0.0015));
1497
+ view.cx = x - (mx - W / 2) / view.k; view.cy = y - (my - H / 2) / view.k;
1498
+ redraw();
1499
+ }, { passive: false });
1500
+ const showCursor = (e) => {
1501
+ const [mx, my] = plotXY(e);
1502
+ if (mx < 0 || my < 0 || mx > W || my > H) { $('cursor').textContent = ''; return; }
1503
+ const x = view.cx + (mx - W / 2) / view.k, y = -(view.cy + (my - H / 2) / view.k);
1504
+ let lat, lon, xy = '';
1505
+ if (hemi) {
1506
+ lat = latOf(Math.hypot(x, y));
1507
+ lon = mod(Math.atan2(x, sgn * y) * DEG, 360);
1508
+ xy = ` x ${(x / 1000).toFixed(2)} km y ${(y / 1000).toFixed(2)} km`;
1509
+ } else {
1510
+ [lat, lon] = [y, mod(x / kx, 360)];
1511
+ }
1512
+ $('cursor').textContent = Math.abs(lat) > 90 ? ''
1513
+ : `Lat ${lat.toFixed(4)} Lon ${lon.toFixed(4)} E${xy}`;
1514
+ };
1515
+ let drag = null;
1516
+ svg.addEventListener('pointerdown', (e) => {
1517
+ drag = { x: e.clientX, y: e.clientY, cx: view.cx, cy: view.cy, i: e.target.dataset?.i, moved: false };
1518
+ try { svg.setPointerCapture(e.pointerId); } catch { /* synthetic event */ }
1519
+ });
1520
+ svg.addEventListener('pointermove', (e) => {
1521
+ showCursor(e);
1522
+ if (!drag) return;
1523
+ const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
1524
+ if (Math.hypot(dx, dy) > 4) { drag.moved = true; svg.classList.add('dragging'); }
1525
+ if (drag.moved) { touched = true; view.cx = drag.cx - dx / view.k; view.cy = drag.cy - dy / view.k; redraw(); }
1526
+ });
1527
+ svg.addEventListener('pointerleave', () => { if (!drag) $('cursor').textContent = ''; });
1528
+ svg.addEventListener('pointerup', () => {
1529
+ if (drag && !drag.moved && drag.i != null) items[Number(drag.i)].onclick();
1530
+ drag = null;
1531
+ svg.classList.remove('dragging');
1532
+ });
1533
+ const home0 = () => { view = null; touched = false; draw(); };
1534
+ svg.addEventListener('dblclick', home0);
1535
+ reset.addEventListener('click', home0);
1536
+ container._highlight = (row) => { sel = items.findIndex((it) => it.row === row); draw(); };
1537
+ let lastW = 0, lastH = 0;
1538
+ container._resize = new ResizeObserver(() => {
1539
+ const [w, h] = [svg.clientWidth, svg.clientHeight];
1540
+ if (w && h && (w !== lastW || h !== lastH)) {
1541
+ [lastW, lastH] = [w, h];
1542
+ // Keep the zoom / pan, just re-measure the plot area
1543
+ const old = view;
1544
+ view = null; fit();
1545
+ if (old && touched) view = old;
1546
+ draw();
1547
+ }
1548
+ });
1549
+ container._resize.observe(svg);
1550
+ draw();
1551
+ }
1552
+
1553
+ function mapHemi(region, lats) {
1554
+ if (region) return polarHemi(region);
1555
+ lats = lats.filter(Number.isFinite);
1556
+ if (lats.length && lats.every((l) => l < -POLAR_LAT_THRESHOLD)) return 'S';
1557
+ if (lats.length && lats.every((l) => l > POLAR_LAT_THRESHOLD)) return 'N';
1558
+ return null;
1559
+ }
1560
+
1561
+ function mapItems(rows, wktCol, hemi, title, onclick) {
1562
+ const items = [];
1563
+ for (const row of rows) {
1564
+ const geom = rowFootprint(row, wktCol, hemi);
1565
+ if (geom) items.push({ geom, row, title: title(row), onclick: () => onclick(row) });
1566
+ }
1567
+ return items;
1568
+ }
1569
+
1570
+ // Newest detail on top of the details pane
1571
+ function showDetail(container, block) {
1572
+ container.prepend(block);
1573
+ container.scrollTop = 0;
1574
+ }
1575
+
1576
+ // QTableView-like grid: cols are [header, numeric]; click or arrow keys select a row
1577
+ function resultTable(cols, rows, cells, onpick) {
1578
+ const tbody = el('tbody');
1579
+ for (const row of rows) {
1580
+ const tr = el('tr', {}, ...cells(row).map((c, i) => el('td', { class: cols[i][1] ? 'num' : i === 0 ? 'mono' : '' },
1581
+ ...(Array.isArray(c) ? c : [c]))));
1582
+ tr._row = row;
1583
+ tr.addEventListener('click', (ev) => { if (!ev.target.closest('a')) onpick(row); });
1584
+ tbody.append(tr);
1585
+ }
1586
+ const wrap = el('div', { class: 'tablewrap', tabindex: '0' }, el('table', { class: 'grid' },
1587
+ el('thead', {}, el('tr', {}, ...cols.map(([h, n]) => el('th', { class: n ? 'num' : '' }, h)))), tbody));
1588
+ wrap.addEventListener('keydown', (e) => {
1589
+ const d = { ArrowDown: 1, ArrowUp: -1 }[e.key];
1590
+ if (!d || !rows.length) return;
1591
+ e.preventDefault();
1592
+ const trs = [...tbody.rows], i = trs.findIndex((t) => t.classList.contains('selected'));
1593
+ onpick(trs[Math.min(Math.max(i + d, 0), trs.length - 1)]._row);
1594
+ });
1595
+ return wrap;
1596
+ }
1597
+
1598
+ // Select a result row (table + map) and show its detail
1599
+ function pick(p, row, detail) {
1600
+ const trs = [...$(`${p}-results`).querySelectorAll('tbody tr')];
1601
+ for (const tr of trs) tr.classList.toggle('selected', tr._row === row);
1602
+ trs.find((tr) => tr._row === row)?.scrollIntoView({ block: 'nearest' });
1603
+ $(`${p}-map`)._highlight?.(row);
1604
+ showDetail($(`${p}-detail`), detail);
1605
+ }
1606
+
1607
+ // DuckDB-WASM
1608
+ async function initDB() {
1609
+ const bundle = await duckdb.selectBundle(duckdb.getJsDelivrBundles());
1610
+ // Blob worker avoids cross-origin worker restrictions
1611
+ const workerUrl = URL.createObjectURL(
1612
+ new Blob([`importScripts("${bundle.mainWorker}");`], { type: 'text/javascript' })
1613
+ );
1614
+ db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(), new Worker(workerUrl));
1615
+ await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
1616
+ URL.revokeObjectURL(workerUrl);
1617
+ conn = await db.connect();
1618
+ for (const m of MACROS) await conn.query(m);
1619
+ }
1620
+
1621
+ // Index sources: ?edr=... URL params, then `lunar serve` (data/<file> next to the page),
1622
+ // then files opened by the user.
1623
+ async function findSources(bust = false) {
1624
+ const params = new URLSearchParams(window.location.search);
1625
+ for (const ds of DATASETS) {
1626
+ const url = params.get(ds);
1627
+ if (url) { sources[ds] = { path: url, label: 'URL' }; continue; }
1628
+ const local = new URL(`data/cumindex_${ds}.parquet${bust ? `?v=${Date.now()}` : ''}`, window.location.href).href;
1629
+ try {
1630
+ const res = await fetch(local, { method: 'HEAD' });
1631
+ if (res.ok) sources[ds] = { path: local, label: 'lunar serve' };
1632
+ } catch { /* not served */ }
1633
+ }
1634
+ }
1635
+
1636
+ async function openFiles(files) {
1637
+ for (const file of files) {
1638
+ const ds = (file.name.match(/(edr|cdr|rdr)/i) || [null, 'edr'])[1].toLowerCase();
1639
+ const name = `cumindex_${ds}_${Date.now()}.parquet`;
1640
+ await db.registerFileHandle(name, file, duckdb.DuckDBDataProtocol.BROWSER_FILEREADER, true);
1641
+ sources[ds] = { path: name, label: file.name };
1642
+ }
1643
+ renderSources();
1644
+ }
1645
+
1646
+ function renderSources() {
1647
+ const loaded = DATASETS.filter((ds) => sources[ds]);
1648
+ const $s = $('sources');
1649
+ $s.replaceChildren();
1650
+ if (!loaded.length) {
1651
+ $s.append('Index: none loaded - open the Parquet files in the control panel');
1652
+ } else {
1653
+ $s.append('Index: ');
1654
+ loaded.forEach((ds, i) => {
1655
+ if (i) $s.append(' | ');
1656
+ $s.append(el('b', {}, ds.toUpperCase()), ` (${sources[ds].label})`);
1657
+ });
1658
+ }
1659
+ const $sel = $('ix-dataset');
1660
+ const current = $sel.value;
1661
+ $sel.replaceChildren(...loaded.map((ds) => el('option', { value: ds }, ds.toUpperCase())));
1662
+ if (loaded.includes(current)) $sel.value = current;
1663
+ $('lookup-btn').disabled = $('ix-search-btn').disabled = !loaded.length;
1664
+ }
1665
+
1666
+ // Plain objects (Arrow rows are read-only proxies)
1667
+ const rowsOf = (tbl) => tbl.toArray().map((r) => r.toJSON());
1668
+
1669
+ const parquet = (ds) => `read_parquet(${sqlStr(sources[ds].path)})`;
1670
+
1671
+ async function columnsOf(ds) {
1672
+ const key = sources[ds].path;
1673
+ columnCache[key] ??= (await conn.query(`SELECT * FROM ${parquet(ds)} LIMIT 0`)).schema.fields.map((f) => f.name);
1674
+ return columnCache[key];
1675
+ }
1676
+
1677
+ // Mirrors constants.pds_url_from_file_spec:
1678
+ // 'LRO-L-LROC-2-EDR-V1.0/LROLRC_0001/...' -> PDS_BASE + 'lro-l-lroc-2-edr/LROLRC_0001/...'
1679
+ function pdsUrl(row) {
1680
+ const fsn = String(row.FILE_SPECIFICATION_NAME ?? '').trim().replace(/^\//, '');
1681
+ if (!fsn) return null;
1682
+ const m = fsn.match(/^(LRO-L-LROC-\d-[A-Z]+)-V\d+\.\d+\/(.*)$/i);
1683
+ return m ? PDS_BASE + m[1].toLowerCase() + '/' + m[2] : PDS_BASE + fsn;
1684
+ }
1685
+
1686
+ function modernize(url) {
1687
+ return url && url.startsWith(LEGACY_PDS_BASE) ? pdsUrl({ FILE_SPECIFICATION_NAME: url.slice(LEGACY_PDS_BASE.length) }) : url;
1688
+ }
1689
+
1690
+ function indexDetail(pid, row, dataset) {
1691
+ const block = el('div', { class: 'detail' }, el('h2', {}, `${pid}${dataset ? ` | ${dataset.toUpperCase()}` : ''}`));
1692
+ if (!row) {
1693
+ block.append(el('p', { class: 'not-found' }, 'Not found in the loaded index files.'));
1694
+ return block;
1695
+ }
1696
+ block._product = indexProduct(row);
1697
+ block._preview = previewPanel(block._product);
1698
+ block.append(block._preview);
1699
+ const tbody = el('tbody');
1700
+ for (const [col, label] of LABELS) {
1701
+ const val = cell(row, col);
1702
+ if (val) tbody.append(el('tr', {}, el('td', { class: 'label' }, label), el('td', {}, val)));
1703
+ }
1704
+ const url = pdsUrl(row);
1705
+ if (url) tbody.append(el('tr', {}, el('td', { class: 'label' }, 'PDS URL'), el('td', {}, link(url))));
1706
+ block.append(el('table', {}, el('thead', {}, el('tr', {}, el('th', {}, 'Property'), el('th', {}, 'Value'))), tbody));
1707
+ return block;
1708
+ }
1709
+
1710
+ const pidOf = (row) => String(row.PRODUCT_ID ?? '').trim();
1711
+ const wktColOf = (row) => (row.FP_WKT_QUERY != null ? 'FP_WKT_QUERY' : 'FP_WKT');
1712
+
1713
+ function indexMap(rows, region) {
1714
+ const hemi = mapHemi(region?.region, rows.map((r) => numOrNull(r.CENTER_LATITUDE)));
1715
+ const items = rows.length ? mapItems(rows, wktColOf(rows[0]), hemi, (r) => `${pidOf(r)} (${r._ds.toUpperCase()})`,
1716
+ pickIndex) : [];
1717
+ renderMap($('index-map'), { hemi, region, items });
1718
+ }
1719
+
1720
+ function pickIndex(r) {
1721
+ const block = indexDetail(pidOf(r), r, r._ds);
1722
+ pick('index', r, block);
1723
+ selectProduct(block);
1724
+ }
1725
+
1726
+ function indexTable(rows) {
1727
+ $('index-results').replaceChildren(resultTable(INDEX_COLUMNS.map(([, h, d]) => [h, d != null]), rows,
1728
+ (row) => INDEX_COLUMNS.map(([col, , d]) => cell(row, col, d)), pickIndex));
1729
+ setCount('index', rows.length);
1730
+ }
1731
+
1732
+ // Product IDs are matched in every loaded dataset (NAC, WAC and RDR alike).
1733
+ async function lookup(ids) {
1734
+ const found = {};
1735
+ const inList = ids.map(sqlStr).join(', ');
1736
+ for (const ds of DATASETS.filter((d) => sources[d])) {
1737
+ const tbl = await conn.query(`SELECT * FROM ${parquet(ds)} WHERE PRODUCT_ID IN (${inList})`);
1738
+ for (const row of rowsOf(tbl)) {
1739
+ const pid = pidOf(row).toUpperCase();
1740
+ if (pid && !found[pid]) found[pid] = { row, ds };
1741
+ }
1742
+ }
1743
+ return found;
1744
+ }
1745
+
1746
+ // Short NAC base form M...L / M...R -> EDR (...LE / ...RE)
1747
+ const normalizeId = (id) => {
1748
+ id = id.trim().toUpperCase();
1749
+ return /^M\d{9,12}[LR]$/.test(id) ? id + 'E' : id;
1750
+ };
1751
+
1752
+ $('lookup-form').addEventListener('submit', async (e) => {
1753
+ e.preventDefault();
1754
+ const ids = $('ids').value.split(/[\s,]+/).filter(Boolean).map(normalizeId);
1755
+ if (!ids.length) return setStatus('index-status', 'Enter one or more product IDs.');
1756
+ setStatus('index-status', 'Querying...');
1757
+ try {
1758
+ const found = await lookup(ids);
1759
+ const rows = Object.values(found).map(({ row, ds }) => Object.assign(row, { _ds: ds }));
1760
+ indexTable(rows);
1761
+ $('index-detail').replaceChildren(...ids.map((id) => indexDetail(id, found[id]?.row, found[id]?.ds)));
1762
+ indexMap(rows, null);
1763
+ const missing = ids.filter((id) => !found[id]);
1764
+ setStatus('index-status', `Lookup: ${Object.keys(found).length} of ${ids.length} found`
1765
+ + (missing.length ? ` | not found: ${missing.join(' ')}` : ''));
1766
+ } catch (err) {
1767
+ setStatus('index-status', err.message, true);
1768
+ }
1769
+ });
1770
+
1771
+ function rangeConds(col, lo, hi) {
1772
+ const c = [];
1773
+ if (lo != null) c.push(`${col} >= ${lo}`);
1774
+ if (hi != null) c.push(`${col} <= ${hi}`);
1775
+ return c;
1776
+ }
1777
+
1778
+ const INDEX_COLUMNS = [
1779
+ ['PRODUCT_ID', 'Product'], ['START_TIME', 'Start'], ['CENTER_LATITUDE', 'Lat', 3],
1780
+ ['CENTER_LONGITUDE', 'Lon', 3], ['INCIDENCE_ANGLE', 'Inc', 1], ['EMISSION_ANGLE', 'Emi', 1],
1781
+ ['SUB_SOLAR_AZIMUTH', 'Sun az', 1], ['RESOLUTION', 'm/px', 2],
1782
+ ];
1783
+
1784
+ $('index-search-form').addEventListener('submit', async (e) => {
1785
+ e.preventDefault();
1786
+ const ds = $('ix-dataset').value;
1787
+ let sel;
1788
+ try { sel = readRegion('ix'); } catch (err) { return setStatus('index-status', err.message, true); }
1789
+ const conds = [
1790
+ regionSql(sel.region),
1791
+ ...rangeConds('INCIDENCE_ANGLE', num('ix-inc-min'), num('ix-inc-max')),
1792
+ ...rangeConds('EMISSION_ANGLE', num('ix-emi-min'), num('ix-emi-max')),
1793
+ ];
1794
+ const camera = $('ix-camera').value;
1795
+ if (camera === 'nac') conds.push(NAC_SQL);
1796
+ if (camera === 'wac') conds.push(`NOT ${NAC_SQL}`);
1797
+ const limit = Math.max(1, Math.min(5000, num('ix-limit') ?? 200));
1798
+ setStatus('index-status', 'Searching...');
1799
+ try {
1800
+ const wkt = footprintWktSql(await columnsOf(ds));
1801
+ const base = `SELECT *, ${wkt} AS FP_WKT_QUERY FROM ${parquet(ds)} WHERE ${conds.join(' AND ')} ORDER BY START_TIME, PRODUCT_ID`;
1802
+ let rows = [], scanned = 0;
1803
+ if (!sel.aoi) {
1804
+ rows = rowsOf(await conn.query(`${base} LIMIT ${limit}`));
1805
+ } else {
1806
+ // Exact AOI refinement after the bbox prefilter; the limit applies afterwards
1807
+ const keep = aoiPredicate(sel.aoi, 'FP_WKT_QUERY');
1808
+ for (let offset = 0; rows.length < limit; offset += AOI_PAGE) {
1809
+ const page = rowsOf(await conn.query(`${base} LIMIT ${AOI_PAGE} OFFSET ${offset}`));
1810
+ scanned += page.length;
1811
+ rows.push(...page.filter(keep));
1812
+ setStatus('index-status', `Searching... ${rows.length} of ${scanned} bbox match(es) intersect the AOI`);
1813
+ if (page.length < AOI_PAGE) break;
1814
+ }
1815
+ rows = rows.slice(0, limit);
1816
+ }
1817
+ rows.forEach((row) => { row._ds = ds; });
1818
+ indexTable(rows);
1819
+ $('index-detail').replaceChildren();
1820
+ indexMap(rows, sel);
1821
+ setStatus('index-status', `${rows.length} image(s)${rows.length === limit ? ' (limit reached)' : ''}`
1822
+ + `${sel.aoi ? ` after exact AOI test of ${scanned} bbox match(es)` : ''} | click a row or footprint for details`);
1823
+ } catch (err) {
1824
+ setStatus('index-status', `${err.message} (does this dataset have FP_* footprint columns?)`, true);
1825
+ }
1826
+ });
1827
+
1828
+ $('files').addEventListener('change', async (e) => {
1829
+ try {
1830
+ await openFiles(e.target.files);
1831
+ setStatus('index-status', 'Ready');
1832
+ } catch (err) {
1833
+ setStatus('index-status', `Could not open: ${err.message}`, true);
1834
+ }
1835
+ });
1836
+
1837
+ // ODE
1838
+ function fillOdeSelects() {
1839
+ $('ode-instrument').replaceChildren(...Object.entries(ODE_INSTRUMENTS).map(
1840
+ ([key, inst]) => el('option', { value: key }, inst.label)));
1841
+ const updateTypes = () => {
1842
+ const inst = ODE_INSTRUMENTS[$('ode-instrument').value];
1843
+ $('ode-type').replaceChildren(...Object.keys(inst.types).map((t) => el('option', { value: t }, t)));
1844
+ $('ode-type').value = inst.default;
1845
+ };
1846
+ $('ode-instrument').addEventListener('change', updateTypes);
1847
+ updateTypes();
1848
+ }
1849
+
1850
+ const odeNum = (v) => (v == null || v === '' ? null : Number(v));
1851
+ const odePid = (prod) => String(prod.pdsid ?? '').split('.').pop();
1852
+
1853
+ function odeFiles(prod) {
1854
+ const f = prod.Product_files?.Product_file ?? [];
1855
+ return (Array.isArray(f) ? f : [f]).map((x) => ({ ...x, URL: modernize(x.URL) }));
1856
+ }
1857
+
1858
+ // Footprint fields as in ode.parse_product
1859
+ const odeFootprint = (prod) => ({
1860
+ footprint_wkt: prod.Footprint_geometry || prod.Footprint_C0_geometry,
1861
+ min_lat: prod.Minimum_latitude, max_lat: prod.Maximum_latitude,
1862
+ lon_west: prod.Westernmost_longitude, lon_east: prod.Easternmost_longitude,
1863
+ });
1864
+
1865
+ // ode.ODEClient.build_params: the region's bounding box, numbers as %.6g
1866
+ function odeParams(sel) {
1867
+ const inst = ODE_INSTRUMENTS[$('ode-instrument').value];
1868
+ const g6 = (x) => String(Number(x.toPrecision(6)));
1869
+ const [west, south, east, north] = regionBbox(sel.region).map(g6);
1870
+ const p = {
1871
+ query: 'product', results: 'fmp', output: 'JSON', target: 'moon',
1872
+ ihid: inst.ihid, iid: inst.iid, pt: inst.types[$('ode-type').value],
1873
+ minlat: south, maxlat: north, westernlon: west, easternlon: east,
1874
+ mininangle: num('ode-inc-min'), maxinangle: num('ode-inc-max'),
1875
+ minemangle: num('ode-emi-min'), maxemangle: num('ode-emi-max'),
1876
+ minobtime: $('ode-t0').value || null, maxobtime: $('ode-t1').value || null,
1877
+ };
1878
+ return Object.fromEntries(Object.entries(p).filter(([, v]) => v != null && v !== ''));
1879
+ }
1880
+
1881
+ function odeDetail(prod) {
1882
+ const block = el('div', { class: 'detail' }, el('h2', {}, odePid(prod)));
1883
+ block._product = odeProduct(prod);
1884
+ block._preview = previewPanel(block._product);
1885
+ block.append(block._preview);
1886
+ const tbody = el('tbody');
1887
+ for (const [k, v] of Object.entries(prod)) {
1888
+ if (typeof v === 'object' || v == null || v === '' || /_geometry$/.test(k)) continue;
1889
+ tbody.append(el('tr', {}, el('td', { class: 'label' }, k.replace(/_/g, ' ')), el('td', {}, String(v))));
1890
+ }
1891
+ for (const f of odeFiles(prod)) {
1892
+ if (!f.URL) continue;
1893
+ tbody.append(el('tr', {}, el('td', { class: 'label' }, `${f.Type ?? 'file'} file`), el('td', {}, link(f.URL, f.FileName ?? f.URL))));
1894
+ }
1895
+ block.append(el('table', {}, tbody));
1896
+ return block;
1897
+ }
1898
+
1899
+ let ode = null; // the submitted query: { params, limit, sel, keep }
1900
+
1901
+ async function odePage(offset) {
1902
+ const res = await fetch(`${ODE_URL}?${new URLSearchParams({ ...ode.params, limit: ode.limit, offset })}`);
1903
+ const json = await res.json();
1904
+ const results = json.ODEResults ?? {};
1905
+ if (results.Error) throw new Error(results.Error);
1906
+ const raw = results.Products?.Product ?? [];
1907
+ return Array.isArray(raw) ? raw : [raw];
1908
+ }
1909
+
1910
+ async function odeSearch(offset, history) {
1911
+ setStatus('ode-status', 'Querying ODE...');
1912
+ $('ode-btn').disabled = true;
1913
+ try {
1914
+ // With an AOI keep paging (up to ODE_AOI_PAGES) until a page's worth survives the exact test
1915
+ let prods = [], next = offset, more = false, pages = 0;
1916
+ do {
1917
+ const page = await odePage(next);
1918
+ next += page.length;
1919
+ more = page.length === ode.limit;
1920
+ prods.push(...(ode.keep ? page.filter((p) => ode.keep(odeFootprint(p))) : page));
1921
+ if (ode.keep) setStatus('ode-status', `Querying ODE... ${prods.length} of ${next - offset} intersect the AOI`);
1922
+ } while (ode.keep && more && prods.length < ode.limit && ++pages < ODE_AOI_PAGES);
1923
+ // One record per product, shared by the table and the map so selection links both
1924
+ const recs = prods.map((p) => Object.assign(odeFootprint(p), { prod: p }));
1925
+ const pickOde = (r) => {
1926
+ const block = odeDetail(r.prod);
1927
+ pick('ode', r, block);
1928
+ selectProduct(block);
1929
+ };
1930
+ const cols = [['Product'], ['Start'], ['Lat', 1], ['Lon', 1], ['Inc', 1], ['Emi', 1], ['m/px', 1], ['Links']];
1931
+ $('ode-results').replaceChildren(resultTable(cols, recs, ({ prod }) => {
1932
+ const data = odeFiles(prod).find((f) => String(f.Type).toLowerCase() === 'product' && !/\.(xml|lbl)$/i.test(f.FileName ?? ''));
1933
+ return [
1934
+ odePid(prod),
1935
+ fmt(prod.UTC_start_time ?? prod.Observation_time),
1936
+ fmt(odeNum(prod.Center_latitude), 3),
1937
+ fmt(odeNum(prod.Center_longitude), 3),
1938
+ fmt(odeNum(prod.Incidence_angle), 1),
1939
+ fmt(odeNum(prod.Emission_angle), 1),
1940
+ fmt(odeNum(prod.Map_resolution ?? prod.Map_scale), 2),
1941
+ [...(data?.URL ? [link(data.URL, 'data')] : []), ...(prod.ProductURL ? [data?.URL ? ' | ' : '', link(prod.ProductURL, 'ODE')] : [])],
1942
+ ];
1943
+ }, pickOde));
1944
+ setCount('ode', recs.length);
1945
+ $('ode-detail').replaceChildren();
1946
+ const nav = [];
1947
+ if (history.length) nav.push(el('button', { class: 'tool', type: 'button', onclick: () => odeSearch(history.at(-1), history.slice(0, -1)) }, '< Previous'));
1948
+ if (more) nav.push(el('button', { class: 'tool', type: 'button', onclick: () => odeSearch(next, [...history, offset]) }, 'Next >'));
1949
+ $('ode-nav').replaceChildren(...nav);
1950
+ const hemi = mapHemi(ode.sel.region, []);
1951
+ renderMap($('ode-map'), {
1952
+ hemi, region: ode.sel,
1953
+ items: mapItems(recs, 'footprint_wkt', hemi, (r) => odePid(r.prod), pickOde),
1954
+ });
1955
+ const range = next > offset ? `ODE results ${offset + 1}-${next}` : '';
1956
+ setStatus('ode-status', !prods.length ? `No products found${range ? ` (${range})` : ''}.`
1957
+ : ode.keep ? `${prods.length} product(s) intersect the AOI (${range}) | click a row or footprint for details`
1958
+ : `Products ${offset + 1}-${next} | click a row or footprint for details`);
1959
+ } catch (err) {
1960
+ setStatus('ode-status', `ODE query failed: ${err.message}`, true);
1961
+ } finally {
1962
+ $('ode-btn').disabled = false;
1963
+ }
1964
+ }
1965
+
1966
+ $('ode-form').addEventListener('submit', (e) => {
1967
+ e.preventDefault();
1968
+ let sel;
1969
+ try { sel = readRegion('ode'); } catch (err) { return setStatus('ode-status', err.message, true); }
1970
+ ode = {
1971
+ sel, params: odeParams(sel), limit: Math.max(1, Math.min(1000, num('ode-limit') ?? 100)),
1972
+ keep: sel.aoi ? aoiPredicate(sel.aoi, 'footprint_wkt') : null,
1973
+ };
1974
+ odeSearch(0, []);
1975
+ });
1976
+
1977
+ // Image previews and the deep-zoom viewer. geotiff.js (loaded on first use) reads tiled TIFF
1978
+ // pyramids / COGs with HTTP Range, one tile per request, so nothing downloads a whole file.
1979
+ const GEOTIFF_URL = 'https://cdn.jsdelivr.net/npm/geotiff@3.0.5/+esm';
1980
+ const TILE_CACHE_BYTES = 150 * 2 ** 20; // decoded tiles + their canvases
1981
+ const OVERVIEW_PX = 400; // preview / base level: the coarsest with a side >= this
1982
+ const MAX_OVERVIEW_PX = 2048; // remote images without a level this small get no preview
1983
+ const STRIP_TILE = 512; // block size for untiled (striped) TIFFs
1984
+ const TILE_REQUESTS = 4; // concurrent tile reads (PDS rate-limits with HTTP 429)
1985
+ // pds.mcp.nasa.gov drops its CORS header for browser requests; lunar serve relays those hosts
1986
+ const RELAY_FIRST = ['pds.mcp.nasa.gov', 'pds.lroc.im-ldi.com'];
1987
+ let geotiffLib = null;
1988
+ let serveConfig = { relay: [], images: false };
1989
+ let localImages = []; // paths from `lunar serve --images`
1990
+
1991
+ const loadGeotiff = () => (geotiffLib ??= import(GEOTIFF_URL).catch((err) => {
1992
+ geotiffLib = null;
1993
+ throw new Error(`Could not load geotiff.js from jsDelivr (${err.message})`);
1994
+ }));
1995
+ const hostOf = (url) => { try { return new URL(url, window.location.href).hostname; } catch { return ''; } };
1996
+ const relayable = (url) => /^https:/i.test(url) && serveConfig.relay.includes(hostOf(url));
1997
+ const relayUrl = (url) => new URL(`remote?url=${encodeURIComponent(url)}`, window.location.href).href;
1998
+ const readUrl = (url, viaRelay = RELAY_FIRST.includes(hostOf(url))) => (viaRelay && relayable(url) ? relayUrl(url) : url);
1999
+ const abortError = () => new DOMException('Aborted', 'AbortError');
2000
+ const fileName = (url) => decodeURIComponent(String(url).split(/[?#]/)[0].split('/').pop() || url);
2001
+
2002
+ // NAC / WAC EDR and CDR: <VOL>/DATA/<phase>/<yyyyddd>/<NAC|WAC>/<ID>.IMG
2003
+ // -> <VOL>/EXTRAS/BROWSE/<yyyyddd>/<ID>_pyr.tif (tiled JPEG, one IFD per 2x level)
2004
+ function lrocPyramidUrl(url) {
2005
+ const m = String(url ?? '').match(/^(.*\/LROLRC_\w+)\/DATA\/[^/]+\/(\d{7})\/(?:NAC|WAC)\/([^/]+)\.IMG$/i);
2006
+ return m ? `${m[1]}/EXTRAS/BROWSE/${m[2]}/${m[3]}_pyr.tif` : null;
2007
+ }
2008
+
2009
+ // Product -> { id, sources: [{ label, url, kind: 'tiff' | 'img', res, samples }] }; res is the
2010
+ // product's m/px, samples its width (to scale res to the pyramid's full-resolution level).
2011
+ function indexProduct(row) {
2012
+ const pyr = lrocPyramidUrl(pdsUrl(row));
2013
+ const meta = { res: numOrNull(row.RESOLUTION), samples: numOrNull(row.LINE_SAMPLES) };
2014
+ return { id: pidOf(row), sources: pyr ? [{ label: 'Browse pyramid', url: pyr, kind: 'tiff', ...meta }] : [] };
2015
+ }
2016
+
2017
+ function odeProduct(prod) {
2018
+ const files = odeFiles(prod).filter((f) => f.URL);
2019
+ const type = (f) => String(f.Type ?? '').toLowerCase();
2020
+ const meta = { res: odeNum(prod.Map_resolution ?? prod.Map_scale), samples: null };
2021
+ const rank = (url) => {
2022
+ const n = fileName(url).toLowerCase();
2023
+ return /_pyr\.tif$/.test(n) ? 0 : /_cog\.tif$/.test(n) && !/smear/.test(n) ? 1
2024
+ : /_map_stretched\.tif$/.test(n) ? 2 : /_map_raw\.tif$/.test(n) ? 3 : 4;
2025
+ };
2026
+ const names = ['Browse pyramid', 'Browse COG', 'Map-projected, stretched', 'Map-projected, raw'];
2027
+ const tifs = files.filter((f) => ['browse', 'referenced'].includes(type(f)) && /\.tiff?$/i.test(f.URL))
2028
+ .sort((a, b) => rank(a.URL) - rank(b.URL));
2029
+ const sources = tifs.map((f) => ({ label: names[rank(f.URL)] ?? fileName(f.URL), url: f.URL, kind: 'tiff', ...meta,
2030
+ ...(rank(f.URL) >= 2 ? { res: null } : {}) }));
2031
+ if (!sources.length) {
2032
+ const img = files.find((f) => type(f) === 'product' && /\.IMG$/i.test(f.URL));
2033
+ const pyr = lrocPyramidUrl(img?.URL);
2034
+ if (pyr) sources.push({ label: 'Browse pyramid', url: pyr, kind: 'tiff', ...meta });
2035
+ }
2036
+ const small = (url) => (/\/small\//i.test(url) ? 1 : 0);
2037
+ for (const f of files.filter((g) => type(g) === 'browse' && /\.(png|jpe?g)$/i.test(g.URL)).sort((a, b) => small(a.URL) - small(b.URL))) {
2038
+ const size = /\/(small|large)\//i.exec(f.URL)?.[1];
2039
+ sources.push({ label: `Browse ${/\.png$/i.test(f.URL) ? 'PNG' : 'JPEG'}${size ? ` (${size})` : ''}`, url: f.URL, kind: 'img', res: null });
2040
+ }
2041
+ return { id: odePid(prod), sources };
2042
+ }
2043
+
2044
+ // The preview uses the first TIFF (its coarsest useful level), else the smallest browse image
2045
+ const previewSource = (prod) => prod.sources.find((s) => s.kind === 'tiff')
2046
+ ?? prod.sources.filter((s) => s.kind === 'img').at(-1);
2047
+
2048
+ // Image sources (header + overview raster), a few recent ones
2049
+ const srcCache = new Map();
2050
+ function cachedSource(key, make) {
2051
+ let p = srcCache.get(key);
2052
+ if (p) { srcCache.delete(key); srcCache.set(key, p); return p; }
2053
+ p = make();
2054
+ srcCache.set(key, p);
2055
+ p.catch(() => { if (srcCache.get(key) === p) srcCache.delete(key); });
2056
+ while (srcCache.size > 6) srcCache.delete(srcCache.keys().next().value);
2057
+ return p;
2058
+ }
2059
+
2060
+ function openTiff(spec) {
2061
+ const key = spec.file ? `file:${spec.file.name}:${spec.file.size}:${spec.file.lastModified}` : spec.url;
2062
+ return cachedSource(key, async () => {
2063
+ const G = await loadGeotiff();
2064
+ let tiff, via = spec.url;
2065
+ if (spec.file) tiff = await G.fromBlob(spec.file);
2066
+ else {
2067
+ via = readUrl(spec.url);
2068
+ // Small blocks for remote tiles (~20-60 KB each); bigger ones for lunar serve --images
2069
+ const sameOrigin = new URL(via, window.location.href).origin === window.location.origin && !/\/remote\?/.test(via);
2070
+ const opts = { allowFullFile: false, ...(sameOrigin ? { blockSize: 65536, cacheSize: 128 } : { blockSize: 16384, cacheSize: 256 }) };
2071
+ try {
2072
+ tiff = await G.fromUrl(via, opts);
2073
+ } catch (err) {
2074
+ if (via !== spec.url || !relayable(spec.url)) throw err;
2075
+ via = relayUrl(spec.url); // direct read failed (CORS?): try the relay
2076
+ tiff = await G.fromUrl(via, opts);
2077
+ }
2078
+ }
2079
+ return describeTiff(tiff, spec, key, via);
2080
+ });
2081
+ }
2082
+
2083
+ function geoMpp(img) {
2084
+ try {
2085
+ const keys = img.getGeoKeys() ?? {};
2086
+ const rx = Math.abs(img.getResolution()[0]);
2087
+ if (!rx) return null;
2088
+ if (keys.ProjectedCSTypeGeoKey || keys.ProjCoordTransGeoKey || keys.ProjectionGeoKey) return rx;
2089
+ if (keys.GeographicTypeGeoKey || keys.GeogAngularUnitsGeoKey) return rx * RAD * MOON_RADIUS_KM * 1000;
2090
+ } catch { /* no georeferencing */ }
2091
+ return null;
2092
+ }
2093
+
2094
+ async function describeTiff(tiff, spec, key, via) {
2095
+ const count = await tiff.getImageCount();
2096
+ const first = await tiff.getImage(0);
2097
+ const levels = [];
2098
+ for (let i = 0; i < count; i++) {
2099
+ const img = i ? await tiff.getImage(i) : first;
2100
+ if (Number(img.fileDirectory.getValue('NewSubfileType') ?? 0) & 4) continue; // transparency mask
2101
+ if (levels.length && img.getWidth() >= levels.at(-1).w) continue; // not a reduced level
2102
+ if (img.getSamplesPerPixel() !== first.getSamplesPerPixel()) continue;
2103
+ levels.push({ img, w: img.getWidth(), h: img.getHeight(),
2104
+ tw: img.isTiled ? img.getTileWidth() : STRIP_TILE, th: img.isTiled ? img.getTileHeight() : STRIP_TILE });
2105
+ }
2106
+ const [w0, h0] = [levels[0].w, levels[0].h];
2107
+ const local = Boolean(spec.file) || new URL(via, window.location.href).origin === window.location.origin && !/\/remote\?/.test(via);
2108
+ // Local files without overviews get decimated "virtual" levels (read with resampling)
2109
+ for (let k = 2, R = levels.at(-1); local && k <= 16 && Math.max(R.w, R.h) * 2 / k > 1024; k *= 2) {
2110
+ // tiles of at most 4096 x 4096 source pixels
2111
+ const t = Math.max(64, Math.min(STRIP_TILE, 4096 / k));
2112
+ levels.push({ img: R.img, base: R, virtual: k, w: Math.ceil(R.w / k), h: Math.ceil(R.h / k), tw: t, th: t });
2113
+ }
2114
+ for (const L of levels) [L.fx, L.fy] = [w0 / L.w, h0 / L.h];
2115
+ const fmtCode = first.getSampleFormat(), bits = first.getBitsPerSample();
2116
+ const rgb = first.getSamplesPerPixel() >= 3 && Number(first.fileDirectory.getValue('PhotometricInterpretation')) === 2;
2117
+ const mpp = geoMpp(first) ?? (spec.res ? spec.res * (spec.samples ? spec.samples / w0 : 1) : null);
2118
+ return {
2119
+ key, via, local, levels, w0, h0, mpp, rgb, samples: rgb ? [0, 1, 2] : [0],
2120
+ int: fmtCode !== 3, noData: first.getGDALNoData(),
2121
+ dtype: `${fmtCode === 3 ? 'float' : fmtCode === 2 ? 'int' : 'uint'}${bits}${rgb ? ' RGB' : ''}`,
2122
+ title: spec.file ? spec.file.name : fileName(spec.url), base: null,
2123
+ };
2124
+ }
2125
+
2126
+ // Browse PNG / JPEG: pixels when the host allows CORS (or via the relay), else a plain <img>
2127
+ function openPicture(spec, signal) {
2128
+ return cachedSource(spec.file ? `file:${spec.file.name}:${spec.file.size}` : spec.url, async () => {
2129
+ const title = spec.file ? spec.file.name : fileName(spec.url);
2130
+ let blob = spec.file;
2131
+ if (!blob) {
2132
+ try {
2133
+ const res = await fetch(readUrl(spec.url, true), { signal });
2134
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
2135
+ blob = await res.blob();
2136
+ } catch (err) {
2137
+ if (signal?.aborted) throw err;
2138
+ const img = await loadImg(spec.url, signal);
2139
+ return { key: spec.url, title, plain: img, w0: img.naturalWidth, h0: img.naturalHeight, levels: [], mpp: null, dtype: 'image (no pixel access)' };
2140
+ }
2141
+ }
2142
+ const bmp = await createImageBitmap(blob);
2143
+ const [w, h] = [bmp.width, bmp.height], n = w * h;
2144
+ const c = Object.assign(document.createElement('canvas'), { width: w, height: h });
2145
+ const ctx = c.getContext('2d', { willReadFrequently: true });
2146
+ ctx.drawImage(bmp, 0, 0);
2147
+ bmp.close();
2148
+ const px = ctx.getImageData(0, 0, w, h).data;
2149
+ c.width = 0;
2150
+ let gray = true;
2151
+ for (let i = 0; i < n && gray; i += Math.max(1, Math.floor(n / 4096))) gray = px[4 * i] === px[4 * i + 1] && px[4 * i] === px[4 * i + 2];
2152
+ const bands = (gray ? [0] : [0, 1, 2]).map((b) => { const a = new Uint8Array(n); for (let i = 0; i < n; i++) a[i] = px[4 * i + b]; return a; });
2153
+ return {
2154
+ key: spec.url ?? title, title, local: true, levels: [{ w, h, tw: w, th: h, fx: 1, fy: 1 }],
2155
+ w0: w, h0: h, mpp: null, rgb: !gray, int: true, noData: null,
2156
+ dtype: gray ? 'uint8' : 'uint8 RGB', samples: gray ? [0] : [0, 1, 2],
2157
+ base: { li: 0, bands, w, h, stats: rasterStats(bands, null, true) },
2158
+ };
2159
+ });
2160
+ }
2161
+
2162
+ function loadImg(url, signal) {
2163
+ return new Promise((resolve, reject) => {
2164
+ const img = new Image();
2165
+ let relayed = false;
2166
+ img.decoding = 'async';
2167
+ img.onload = () => resolve(img);
2168
+ img.onerror = () => {
2169
+ if (!relayed && relayable(url)) { relayed = true; img.src = relayUrl(url); return; }
2170
+ reject(new Error(`Could not load ${fileName(url)}`));
2171
+ };
2172
+ signal?.addEventListener('abort', () => { img.removeAttribute('src'); reject(abortError()); });
2173
+ img.src = url;
2174
+ });
2175
+ }
2176
+
2177
+ // Read a window of level L (level pixel coords); virtual levels resample the coarsest real one
2178
+ function readWindow(src, L, [x0, y0, x1, y1], signal) {
2179
+ if (!L.virtual) return L.img.readRasters({ window: [x0, y0, x1, y1], samples: src.samples, signal });
2180
+ const k = L.virtual, R = L.base;
2181
+ return L.img.readRasters({
2182
+ window: [x0 * k, y0 * k, Math.min(x1 * k, R.w), Math.min(y1 * k, R.h)],
2183
+ width: x1 - x0, height: y1 - y0, resampleMethod: 'nearest', samples: src.samples, signal,
2184
+ });
2185
+ }
2186
+
2187
+ const isFill = (v, noData) => v !== v || v === noData || v > 1e30 || v < -1e30;
2188
+
2189
+ // Percentiles from a sample of the valid pixels; 0-filled margins of integer images are skipped
2190
+ function rasterStats(bands, noData, int) {
2191
+ const n = bands[0].length, step = Math.max(1, Math.floor((n * bands.length) / 200000));
2192
+ let vals = [];
2193
+ for (const a of bands) for (let i = 0; i < n; i += step) if (!isFill(a[i], noData)) vals.push(a[i]);
2194
+ if (int && vals.filter((v) => v === 0).length > 0.1 * vals.length) vals = vals.filter((v) => v !== 0);
2195
+ if (!vals.length) return null;
2196
+ const f = Float64Array.from(vals).sort();
2197
+ const q = (p) => f[Math.min(f.length - 1, Math.floor(p * (f.length - 1)))];
2198
+ return { min: f[0], max: f.at(-1), p2: q(0.02), p98: q(0.98) };
2199
+ }
2200
+
2201
+ function autoStretch(stats, int) {
2202
+ if (!stats) return { min: 0, max: 255, gamma: 1 };
2203
+ let [min, max] = stats.p98 > stats.p2 ? [stats.p2, stats.p98] : [stats.min, stats.max];
2204
+ if (!(max > min)) max = min + 1;
2205
+ if (int) [min, max] = [Math.floor(min), Math.ceil(max)];
2206
+ return { min, max, gamma: 1 };
2207
+ }
2208
+
2209
+ // Raster bands -> canvas through the linear stretch + gamma (look-up tables for 8 / 16 bit)
2210
+ function paint(bands, w, h, st, noData, canvas = document.createElement('canvas')) {
2211
+ if (canvas.width !== w || canvas.height !== h) [canvas.width, canvas.height] = [w, h];
2212
+ const ctx = canvas.getContext('2d');
2213
+ const im = ctx.createImageData(w, h), d = im.data, n = w * h;
2214
+ const k = 1 / (st.max - st.min || 1), g = 1 / (st.gamma > 0 ? st.gamma : 1), lo = st.min;
2215
+ const f = (v) => { let t = (v - lo) * k; t = t <= 0 ? 0 : t >= 1 ? 1 : t; return g === 1 ? t * 255 : 255 * t ** g; };
2216
+ const lut = (a) => {
2217
+ const size = a instanceof Uint8Array ? 256 : a instanceof Uint16Array ? 65536 : 0;
2218
+ if (!size) return null;
2219
+ const key = `_lut${size}`;
2220
+ if (!st[key]) { st[key] = new Uint8ClampedArray(size); for (let v = 0; v < size; v++) st[key][v] = f(v); }
2221
+ return st[key];
2222
+ };
2223
+ const nb = bands.length >= 3 ? 3 : 1;
2224
+ for (let b = 0; b < nb; b++) {
2225
+ const a = bands[b], t = lut(a);
2226
+ if (t) for (let i = 0, j = b; i < n; i++, j += 4) d[j] = t[a[i]];
2227
+ else for (let i = 0, j = b; i < n; i++, j += 4) d[j] = f(a[i]);
2228
+ }
2229
+ const a0 = bands[0];
2230
+ for (let i = 0, j = 0; i < n; i++, j += 4) {
2231
+ if (nb === 1) d[j + 1] = d[j + 2] = d[j];
2232
+ d[j + 3] = isFill(a0[i], noData) ? 0 : 255;
2233
+ }
2234
+ ctx.putImageData(im, 0, 0);
2235
+ return canvas;
2236
+ }
2237
+
2238
+ // Overview raster: the coarsest level with a side >= OVERVIEW_PX (whole level, a few tiles)
2239
+ async function ensureBase(src, signal) {
2240
+ if (src.base || src.plain) return src.base;
2241
+ let li = 0;
2242
+ for (let i = src.levels.length - 1; i >= 0; i--) {
2243
+ if (Math.max(src.levels[i].w, src.levels[i].h) >= OVERVIEW_PX) { li = i; break; }
2244
+ }
2245
+ const L = src.levels[li];
2246
+ if (Math.max(L.w, L.h) > MAX_OVERVIEW_PX && !src.local) return null;
2247
+ let bands;
2248
+ if (!L.virtual) bands = Array.from(await readWindow(src, L, [0, 0, L.w, L.h], signal));
2249
+ else {
2250
+ // Tile by tile, so a decimated read never holds the whole source image
2251
+ const Ctor = (await readWindow(src, L, [0, 0, 1, 1], signal))[0].constructor;
2252
+ bands = src.samples.map(() => new Ctor(L.w * L.h));
2253
+ for (let y = 0; y < L.h; y += L.th) {
2254
+ for (let x = 0; x < L.w; x += L.tw) {
2255
+ const r = await readWindow(src, L, [x, y, Math.min(x + L.tw, L.w), Math.min(y + L.th, L.h)], signal);
2256
+ r.forEach((a, b) => { for (let j = 0; j < r.height; j++) bands[b].set(a.subarray(j * r.width, (j + 1) * r.width), (y + j) * L.w + x); });
2257
+ }
2258
+ }
2259
+ }
2260
+ src.base ??= { li, bands, w: L.w, h: L.h, stats: rasterStats(bands, src.noData, src.int) };
2261
+ return src.base;
2262
+ }
2263
+
2264
+ // Quick look in a detail block; one at a time, cancelled when the selection changes
2265
+ let previewCtl = null;
2266
+
2267
+ function previewPanel(prod) {
2268
+ if (!prod.sources.length) {
2269
+ return el('div', { class: 'preview none' }, 'No browse image or pyramid known for this product.');
2270
+ }
2271
+ const box = el('div', { class: 'pvbox' }, el('span', { class: 'msg' }, 'Preview loads when the product is selected'));
2272
+ const cap = el('span', { class: 'info' }, previewSource(prod).label);
2273
+ const panel = el('div', { class: 'preview' }, box, el('div', { class: 'pvcap' }, cap, el('span', { class: 'right' },
2274
+ el('button', { class: 'tool', type: 'button', title: 'Load the preview', onclick: () => panel._load() }, 'Preview'),
2275
+ el('button', { class: 'tool', type: 'button', title: 'Show in the Image view (deep zoom)',
2276
+ onclick: () => { selectView('image'); viewProduct(prod); } }, 'Open in viewer'))));
2277
+ panel._load = () => loadPreview(prod, box, cap);
2278
+ return panel;
2279
+ }
2280
+
2281
+ async function loadPreview(prod, box, cap) {
2282
+ previewCtl?.abort();
2283
+ const ctl = previewCtl = new AbortController();
2284
+ const spec = previewSource(prod);
2285
+ const msg = (text, cls = 'msg') => box.replaceChildren(el('span', { class: cls }, text));
2286
+ msg(`Loading preview... (${spec.label})`);
2287
+ try {
2288
+ if (spec.kind === 'img') {
2289
+ const img = await loadImg(spec.url, ctl.signal);
2290
+ img.alt = `${prod.id} browse image`;
2291
+ box.replaceChildren(img);
2292
+ cap.textContent = `${spec.label}: ${img.naturalWidth} x ${img.naturalHeight}`;
2293
+ return;
2294
+ }
2295
+ const src = await openTiff(spec);
2296
+ if (ctl.signal.aborted) throw abortError();
2297
+ const base = await ensureBase(src, ctl.signal);
2298
+ if (ctl.signal.aborted) throw abortError();
2299
+ if (!base) {
2300
+ msg('No overview level small enough for a quick look - use Open in viewer.');
2301
+ return;
2302
+ }
2303
+ const canvas = paint(base.bands, base.w, base.h, autoStretch(base.stats, src.int), src.noData);
2304
+ canvas.title = `${src.title}: level ${base.li} of ${src.levels.length}`;
2305
+ box.replaceChildren(canvas);
2306
+ cap.textContent = `${spec.label}: level ${base.li} (${base.w} x ${base.h}) of ${src.w0} x ${src.h0}, ${src.dtype}`;
2307
+ } catch (err) {
2308
+ if (ctl.signal.aborted || err?.name === 'AbortError') {
2309
+ if (!box.querySelector('canvas, img')) msg('Preview not loaded (selection changed) - press Preview.');
2310
+ return;
2311
+ }
2312
+ const text = await explainError(spec.url, err);
2313
+ msg(text, 'msg error');
2314
+ setStatus(`${activeTab()}-status`, `Preview: ${text}`, true);
2315
+ } finally {
2316
+ if (previewCtl === ctl) previewCtl = null;
2317
+ }
2318
+ }
2319
+
2320
+ async function explainError(url, err) {
2321
+ if (!url || /geotiff\.js/.test(err?.message ?? '')) return err?.message ?? String(err);
2322
+ const name = fileName(url), host = hostOf(url);
2323
+ try {
2324
+ // Through the relay when there is one: error responses often lack CORS headers
2325
+ const res = await fetch(readUrl(url, relayable(url)), { headers: { Range: 'bytes=0-0' } });
2326
+ res.body?.cancel();
2327
+ if (res.status === 404) return `Not found (HTTP 404): ${name}`;
2328
+ if (res.status === 403 && /\/remote\?/.test(res.url)) return `lunar serve does not relay ${host}`;
2329
+ if (res.status === 429 || res.status === 503) return `${host} is rate-limiting requests (HTTP ${res.status})`;
2330
+ if (!res.ok) return `HTTP ${res.status} from ${host} for ${name}`;
2331
+ if (res.status === 200) return `${host} ignores HTTP Range requests, so ${name} would need a full download (not done)`;
2332
+ return `Could not read ${name}: ${err?.message ?? err}`;
2333
+ } catch {
2334
+ return `Network or CORS error reading ${name} from ${host}: the server must allow cross-origin Range requests`
2335
+ + (serveConfig.relay.length ? '' : ' (lunar serve relays the PDS image hosts)');
2336
+ }
2337
+ }
2338
+
2339
+ // Decoded tiles, least recently used first; bytes count the raster and its painted canvas
2340
+ const tiles = new Map();
2341
+ let tileBytes = 0;
2342
+ function tileGet(key) {
2343
+ const t = tiles.get(key);
2344
+ if (t) { tiles.delete(key); tiles.set(key, t); }
2345
+ return t;
2346
+ }
2347
+ function tileAdd(key, t, delta) {
2348
+ if (key) tiles.set(key, t);
2349
+ t.bytes += delta;
2350
+ tileBytes += delta;
2351
+ for (const [k, old] of tiles) {
2352
+ if (tileBytes <= TILE_CACHE_BYTES || tiles.size <= 1) break;
2353
+ tiles.delete(k);
2354
+ tileBytes -= old.bytes;
2355
+ if (old.canvas) old.canvas.width = 0;
2356
+ }
2357
+ }
2358
+
2359
+ // Viewer state. view = { cx, cy, s }: image point (full-res pixels) at the canvas centre and
2360
+ // CSS px per full-res pixel; stretch = { min, max, gamma }, ver bumps when it changes.
2361
+ const V = {
2362
+ src: null, spec: null, product: null, pending: null, choices: [], file: null, view: null, touched: false,
2363
+ stretch: { min: 0, max: 255, gamma: 1 }, ver: 0, ctl: null, wanted: [], inflight: new Set(), failed: new Map(),
2364
+ priority: [], hover: null, frame: 0, readoutTimer: 0, pumpTimer: 0, pauseUntil: 0, backoff: 0,
2365
+ };
2366
+ const viewCanvas = $('view-canvas');
2367
+ const frameSize = () => [viewCanvas.clientWidth, viewCanvas.clientHeight];
2368
+ const viewActive = () => ($('view-image').hidden ? 'map' : 'image');
2369
+ const dpr = () => window.devicePixelRatio || 1;
2370
+
2371
+ function viewMessage(text, isError = false) {
2372
+ $('view-msg').textContent = text;
2373
+ $('view-msg').className = 'viewmsg' + (isError ? ' error' : '');
2374
+ }
2375
+
2376
+ function viewError(text) {
2377
+ viewMessage(text, true);
2378
+ setStatus(`${activeTab()}-status`, `Image: ${text}`, true);
2379
+ }
2380
+
2381
+ const fitScale = (mode) => {
2382
+ const [cw, ch] = frameSize(), { w0, h0 } = V.src;
2383
+ return mode === 'width' ? cw / w0 : Math.min(cw / w0, ch / h0);
2384
+ };
2385
+
2386
+ function fitView(mode) {
2387
+ const [, ch] = frameSize(), s = fitScale(mode) * (mode === 'width' ? 1 : 0.98);
2388
+ V.view = { s, cx: V.src.w0 / 2, cy: mode === 'width' ? Math.min(V.src.h0 / 2, ch / 2 / s) : V.src.h0 / 2 };
2389
+ }
2390
+
2391
+ function viewFit(mode = 'all') {
2392
+ if (!V.src) return;
2393
+ fitView(mode);
2394
+ V.touched = mode !== 'all';
2395
+ viewRedraw();
2396
+ }
2397
+
2398
+ function viewClamp() {
2399
+ const v = V.view, src = V.src;
2400
+ let minS = Math.min(fitScale('all') * 0.5, 1);
2401
+ // Remote images without a small overview: don't zoom out past what the coarsest level fills
2402
+ if (!src.base && !src.plain && src.levels.length) minS = Math.max(minS, 0.5 / (src.levels.at(-1).fx * dpr()));
2403
+ v.s = Math.min(Math.max(v.s, minS), 32);
2404
+ v.cx = Math.min(Math.max(v.cx, 0), src.w0);
2405
+ v.cy = Math.min(Math.max(v.cy, 0), src.h0);
2406
+ }
2407
+
2408
+ function viewZoom(f, mx, my) {
2409
+ if (!V.src || !V.view) return;
2410
+ const v = V.view, [cw, ch] = frameSize();
2411
+ [mx, my] = [mx ?? cw / 2, my ?? ch / 2];
2412
+ const x = v.cx + (mx - cw / 2) / v.s, y = v.cy + (my - ch / 2) / v.s;
2413
+ v.s *= f;
2414
+ viewClamp();
2415
+ v.cx = x - (mx - cw / 2) / v.s;
2416
+ v.cy = y - (my - ch / 2) / v.s;
2417
+ viewClamp();
2418
+ V.touched = true;
2419
+ viewRedraw();
2420
+ }
2421
+
2422
+ function viewSetScale(s) {
2423
+ if (!V.view) return;
2424
+ V.view.s = s;
2425
+ viewClamp();
2426
+ V.touched = true;
2427
+ viewRedraw();
2428
+ }
2429
+
2430
+ const viewRedraw = () => {
2431
+ if (!V.frame) V.frame = requestAnimationFrame(() => { V.frame = 0; viewDraw(); });
2432
+ };
2433
+
2434
+ // Finest level whose pixels are no smaller than a device pixel at scale s
2435
+ function targetLevel(src, s) {
2436
+ const want = 1 / (s * dpr());
2437
+ let li = 0;
2438
+ src.levels.forEach((L, i) => { if (L.fx <= want * 1.0001) li = i; });
2439
+ return li;
2440
+ }
2441
+
2442
+ function tileCanvas(t, src) {
2443
+ if (!t.canvas || t.ver !== V.ver) {
2444
+ const fresh = !t.canvas;
2445
+ t.canvas = paint(t.bands, t.w, t.h, V.stretch, src.noData, t.canvas ?? undefined);
2446
+ t.ver = V.ver;
2447
+ if (fresh && t !== src.base) tileAdd(null, t, t.w * t.h * 4);
2448
+ }
2449
+ return t.canvas;
2450
+ }
2451
+
2452
+ function viewDraw() {
2453
+ const [cw, ch] = frameSize(), r = dpr();
2454
+ if (!cw || !ch) return;
2455
+ if (viewCanvas.width !== Math.round(cw * r) || viewCanvas.height !== Math.round(ch * r)) {
2456
+ [viewCanvas.width, viewCanvas.height] = [Math.round(cw * r), Math.round(ch * r)];
2457
+ }
2458
+ const ctx = viewCanvas.getContext('2d');
2459
+ ctx.setTransform(r, 0, 0, r, 0, 0);
2460
+ ctx.clearRect(0, 0, cw, ch);
2461
+ const src = V.src;
2462
+ if (!src) { viewStatus(); return; }
2463
+ if (!V.view || !V.touched) fitView('all');
2464
+ viewClamp();
2465
+ const { cx, cy, s } = V.view;
2466
+ const X = (x) => Math.round((x - cx) * s + cw / 2), Y = (y) => Math.round((y - cy) * s + ch / 2);
2467
+ const put = (canvas, x0, y0, x1, y1, sharp) => {
2468
+ ctx.imageSmoothingEnabled = !sharp;
2469
+ ctx.drawImage(canvas, X(x0), Y(y0), Math.max(1, X(x1) - X(x0)), Math.max(1, Y(y1) - Y(y0)));
2470
+ };
2471
+ if (src.plain) {
2472
+ put(src.plain, 0, 0, src.w0, src.h0, s * r >= 1.5);
2473
+ viewStatus(0);
2474
+ return;
2475
+ }
2476
+ const vx0 = cx - cw / 2 / s, vx1 = cx + cw / 2 / s, vy0 = cy - ch / 2 / s, vy1 = cy + ch / 2 / s;
2477
+ let target = targetLevel(src, s);
2478
+ if (src.base) target = Math.min(target, src.base.li);
2479
+ // The overview first (blurry stand-in), then the cached tiles of each finer level
2480
+ if (src.base) put(tileCanvas(src.base, src), 0, 0, src.w0, src.h0, src.base.li === target && s * src.levels[target].fx * r >= 1.5);
2481
+ const want = [];
2482
+ for (let li = (src.base ? src.base.li - 1 : src.levels.length - 1); li >= target; li--) {
2483
+ const L = src.levels[li], m = li === target ? 1 : 0;
2484
+ const [tx0, tx1] = [Math.max(0, Math.floor(vx0 / L.fx / L.tw) - m), Math.min(Math.ceil(L.w / L.tw) - 1, Math.floor(vx1 / L.fx / L.tw) + m)];
2485
+ const [ty0, ty1] = [Math.max(0, Math.floor(vy0 / L.fy / L.th) - m), Math.min(Math.ceil(L.h / L.th) - 1, Math.floor(vy1 / L.fy / L.th) + m)];
2486
+ for (let ty = ty0; ty <= ty1; ty++) {
2487
+ for (let tx = tx0; tx <= tx1; tx++) {
2488
+ const key = `${src.key}|${li}|${tx}|${ty}`;
2489
+ const t = tileGet(key);
2490
+ const [x0, y0] = [tx * L.tw * L.fx, ty * L.th * L.fy];
2491
+ const [x1, y1] = [Math.min((tx + 1) * L.tw, L.w) * L.fx, Math.min((ty + 1) * L.th, L.h) * L.fy];
2492
+ if (t) put(tileCanvas(t, src), x0, y0, x1, y1, li === target && s * L.fx * r >= 1.5);
2493
+ else if (li === target) want.push({ key, li, tx, ty, d: Math.hypot((x0 + x1) / 2 - cx, (y0 + y1) / 2 - cy) });
2494
+ }
2495
+ }
2496
+ }
2497
+ V.wanted = want.sort((a, b) => a.d - b.d);
2498
+ // Fetch once the view rests, so fast pans don't request tiles that scroll straight past
2499
+ clearTimeout(V.pumpTimer);
2500
+ V.pumpTimer = setTimeout(viewPump, 80);
2501
+ scaleBar(ctx, ch, s);
2502
+ viewStatus(target);
2503
+ }
2504
+
2505
+ function scaleBar(ctx, ch, s) {
2506
+ const mpp = V.src.mpp;
2507
+ if (!mpp) return;
2508
+ const per = mpp / s; // metres per CSS px
2509
+ const len = step125(per * 120, 1), px = len / per;
2510
+ const text = len >= 1000 ? `${len / 1000} km` : len >= 1 ? `${len} m` : `${len * 100} cm`;
2511
+ const [x, y] = [12, ch - 14];
2512
+ ctx.lineCap = 'butt';
2513
+ for (const [color, width] of [['rgba(0,0,0,0.75)', 5], ['#ffffff', 2]]) {
2514
+ ctx.strokeStyle = color; ctx.lineWidth = width;
2515
+ ctx.beginPath(); ctx.moveTo(x, y - 5); ctx.lineTo(x, y); ctx.lineTo(x + px, y); ctx.lineTo(x + px, y - 5); ctx.stroke();
2516
+ }
2517
+ ctx.font = '11px "DejaVu Sans", Arial, sans-serif';
2518
+ ctx.lineWidth = 3; ctx.strokeStyle = 'rgba(0,0,0,0.75)'; ctx.fillStyle = '#ffffff';
2519
+ ctx.strokeText(text, x + 4, y - 7); ctx.fillText(text, x + 4, y - 7);
2520
+ }
2521
+
2522
+ const fmtMpp = (m) => (m >= 100 ? m.toFixed(0) : m >= 1 ? m.toFixed(2) : m.toPrecision(2));
2523
+
2524
+ function viewStatus(target) {
2525
+ const src = V.src;
2526
+ const mb = (b) => (b / 2 ** 20).toFixed(1);
2527
+ $('view-info').textContent = !src ? (V.spec ? `${V.spec.label ?? ''}${V.spec === V.failedSpec ? ': not loaded' : ' - loading...'}` : 'No image')
2528
+ : `${src.title} | ${src.w0} x ${src.h0} px, ${src.levels.length || 1} level(s), ${src.dtype}`
2529
+ + ` | tile cache ${tiles.size} tiles, ${mb(tileBytes)} / ${mb(TILE_CACHE_BYTES)} MB`;
2530
+ if (!src || !V.view) { $('view-zoom').textContent = ''; return; }
2531
+ const L = src.levels[target];
2532
+ const zoom = V.view.s * dpr() * 100;
2533
+ $('view-zoom').textContent = `Zoom ${zoom >= 10 ? zoom.toFixed(0) : zoom.toPrecision(2)}%`
2534
+ + (L && src.levels.length > 1 ? ` | level ${target} (${L.w} x ${L.h})` : '')
2535
+ + (src.mpp ? ` | ${fmtMpp(src.mpp)} m/px, ${fmtMpp(src.mpp / (V.view.s * dpr()))} m per screen px` : '');
2536
+ }
2537
+
2538
+ function viewPump() {
2539
+ const wait = V.pauseUntil - Date.now();
2540
+ if (wait > 0) {
2541
+ clearTimeout(V.pumpTimer);
2542
+ V.pumpTimer = setTimeout(viewRedraw, wait + 50);
2543
+ return;
2544
+ }
2545
+ while (V.inflight.size < TILE_REQUESTS && (V.priority.length || V.wanted.length)) {
2546
+ const job = V.priority.shift() ?? V.wanted.shift();
2547
+ if (V.inflight.has(job.key) || tiles.has(job.key) || (V.failed.get(job.key) ?? 0) >= 3) continue;
2548
+ V.inflight.add(job.key);
2549
+ loadTile(V.src, job, V.ctl).finally(() => { V.inflight.delete(job.key); viewPump(); });
2550
+ }
2551
+ }
2552
+
2553
+ async function loadTile(src, { key, li, tx, ty }, ctl) {
2554
+ const L = src.levels[li];
2555
+ try {
2556
+ const [x0, y0] = [tx * L.tw, ty * L.th];
2557
+ const r = await readWindow(src, L, [x0, y0, Math.min(x0 + L.tw, L.w), Math.min(y0 + L.th, L.h)], ctl.signal);
2558
+ const bands = Array.from(r);
2559
+ tileAdd(key, { bands, w: r.width, h: r.height, canvas: null, ver: -1, bytes: 0 }, bands.reduce((a, b) => a + b.byteLength, 0));
2560
+ V.backoff = 0;
2561
+ if (V.src === src) {
2562
+ if (src.errorShown) { src.errorShown = false; viewMessage(''); }
2563
+ if (V.stretch.pending) setStretch(autoStretch(rasterStats(bands, src.noData, src.int), src.int));
2564
+ viewRedraw();
2565
+ if (V.hover) viewReadout();
2566
+ }
2567
+ } catch (err) {
2568
+ if (ctl.signal.aborted || err?.name === 'AbortError') return;
2569
+ // Pause all tile reads (doubling up to a minute), then retry; a tile failing 3 times is skipped
2570
+ V.failed.set(key, (V.failed.get(key) ?? 0) + 1);
2571
+ V.backoff = Math.min(Math.max(2 * V.backoff, 2000), 60000);
2572
+ V.pauseUntil = Date.now() + V.backoff;
2573
+ if (V.src === src) {
2574
+ const why = src.errorShown ? src.why : (src.why = await explainError(V.spec?.url, err));
2575
+ if (V.src !== src) return;
2576
+ src.errorShown = true;
2577
+ viewError(`${why} - retrying tiles in ${Math.round(V.backoff / 1000)} s`);
2578
+ }
2579
+ }
2580
+ }
2581
+
2582
+ // Pixel readout: 1-based sample / line at full resolution (ISIS convention); the value comes
2583
+ // from the full-resolution tile, fetched after the pointer rests, meanwhile from a coarser level
2584
+ function pixelAt(src, sx, sy) {
2585
+ const levels = src.levels.length ? src.levels : [];
2586
+ for (let li = 0; li < levels.length; li++) {
2587
+ const L = levels[li], lx = Math.floor(sx / L.fx), ly = Math.floor(sy / L.fy);
2588
+ const tx = Math.floor(lx / L.tw), ty = Math.floor(ly / L.th);
2589
+ const t = src.base?.li === li ? src.base : tiles.get(`${src.key}|${li}|${tx}|${ty}`);
2590
+ if (!t) continue;
2591
+ const i = src.base?.li === li ? ly * t.w + lx : (ly - ty * L.th) * t.w + (lx - tx * L.tw);
2592
+ return { li, v: t.bands.map((b) => b[i]), tx, ty };
2593
+ }
2594
+ return null;
2595
+ }
2596
+
2597
+ function viewReadout() {
2598
+ const src = V.src, h = V.hover;
2599
+ if (!src || !V.view || !h) return;
2600
+ const [cw, ch] = frameSize(), { cx, cy, s } = V.view;
2601
+ const x = cx + (h.mx - cw / 2) / s, y = cy + (h.my - ch / 2) / s;
2602
+ if (x < 0 || y < 0 || x >= src.w0 || y >= src.h0) { $('cursor').textContent = ''; return; }
2603
+ const [sx, sy] = [Math.floor(x), Math.floor(y)];
2604
+ let text = `Sample ${sx + 1} Line ${sy + 1}`;
2605
+ if (src.plain) {
2606
+ text += ' (no pixel access: the host does not allow CORS)';
2607
+ } else {
2608
+ const p = pixelAt(src, sx, sy);
2609
+ if (p) {
2610
+ const v = p.v.map((a) => (isFill(a, src.noData) ? 'no data' : src.int ? String(a) : Number(a).toPrecision(6)));
2611
+ text += ` ${src.rgb ? 'RGB' : src.int ? 'DN' : 'Value'} ${v.join(' ')}${p.li ? ` (level ${p.li})` : ''}`;
2612
+ }
2613
+ if (!p || p.li) {
2614
+ clearTimeout(V.readoutTimer);
2615
+ V.readoutTimer = setTimeout(() => {
2616
+ const L = src.levels[0];
2617
+ const key = `${src.key}|0|${Math.floor(sx / L.tw)}|${Math.floor(sy / L.th)}`;
2618
+ if (V.src === src && !tiles.has(key)) {
2619
+ V.priority = [{ key, li: 0, tx: Math.floor(sx / L.tw), ty: Math.floor(sy / L.th) }];
2620
+ viewPump();
2621
+ }
2622
+ }, 250);
2623
+ }
2624
+ }
2625
+ $('cursor').textContent = text;
2626
+ }
2627
+
2628
+ function setStretch(st) {
2629
+ V.stretch = { min: st.min, max: st.max, gamma: st.gamma ?? 1 };
2630
+ V.ver++;
2631
+ const int = V.src?.int;
2632
+ const show = (v) => (int ? String(Math.round(v)) : String(Number(v.toPrecision(6))));
2633
+ $('view-min').value = show(V.stretch.min);
2634
+ $('view-max').value = show(V.stretch.max);
2635
+ $('view-gamma').value = String(V.stretch.gamma);
2636
+ for (const id of ['view-min', 'view-max']) $(id).step = int ? '1' : 'any';
2637
+ viewRedraw();
2638
+ }
2639
+
2640
+ function enableControls(on) {
2641
+ for (const id of ['view-min', 'view-max', 'view-gamma', 'view-auto']) $(id).disabled = !on;
2642
+ }
2643
+
2644
+ // Choices for the source drop-down: the selected product's images, served images, an opened file
2645
+ function fillChoices() {
2646
+ const groups = [];
2647
+ if (V.product) groups.push([`Product ${V.product.id}`, V.product.sources]);
2648
+ if (localImages.length) {
2649
+ groups.push(['lunar serve --images', localImages.map((p) => ({
2650
+ label: p, kind: /\.(png|jpe?g)$/i.test(p) ? 'img' : 'tiff',
2651
+ url: new URL(`images/${p.split('/').map(encodeURIComponent).join('/')}`, window.location.href).href,
2652
+ }))]);
2653
+ }
2654
+ if (V.file) groups.push(['Local file', [V.file]]);
2655
+ V.choices = groups.flatMap(([, list]) => list);
2656
+ let i = 0;
2657
+ $('view-source').replaceChildren(...(V.choices.length ? [] : [el('option', { value: '' }, '(no image)')]),
2658
+ ...groups.map(([name, list]) => el('optgroup', { label: name }, ...list.map((c) => el('option', { value: String(i++) }, c.label)))));
2659
+ $('view-source').disabled = !V.choices.length;
2660
+ const at = V.choices.indexOf(V.spec);
2661
+ if (at >= 0) $('view-source').value = String(at);
2662
+ }
2663
+
2664
+ function viewProduct(prod) {
2665
+ if (V.product && V.product.id === prod.id && V.product.sources[0]?.url === prod.sources[0]?.url && V.src) return;
2666
+ V.product = prod;
2667
+ fillChoices();
2668
+ if (prod.sources.length) viewOpen(prod.sources[0]);
2669
+ else {
2670
+ viewClose();
2671
+ viewMessage(`${prod.id}: no browse image or pyramid known for this product.`);
2672
+ }
2673
+ }
2674
+
2675
+ function viewClose() {
2676
+ V.ctl?.abort();
2677
+ Object.assign(V, { src: null, spec: null, view: null, wanted: [], priority: [], ctl: null });
2678
+ V.failed.clear();
2679
+ enableControls(false);
2680
+ $('cursor').textContent = '';
2681
+ viewRedraw();
2682
+ }
2683
+
2684
+ async function viewOpen(spec) {
2685
+ viewClose();
2686
+ const ctl = V.ctl = new AbortController();
2687
+ V.spec = spec;
2688
+ fillChoices();
2689
+ viewMessage(`Loading ${spec.label ?? spec.file?.name ?? ''}...`);
2690
+ try {
2691
+ const src = await (spec.kind === 'img' ? openPicture(spec, ctl.signal) : openTiff(spec));
2692
+ if (ctl.signal.aborted) return;
2693
+ await ensureBase(src, ctl.signal);
2694
+ if (ctl.signal.aborted) return;
2695
+ Object.assign(V, { src, view: null, touched: false });
2696
+ src.errorShown = false;
2697
+ if (src.plain) {
2698
+ V.stretch = { min: 0, max: 255, gamma: 1 };
2699
+ enableControls(false);
2700
+ } else {
2701
+ enableControls(true);
2702
+ setStretch(autoStretch(src.base?.stats, src.int));
2703
+ if (!src.base) V.stretch.pending = true;
2704
+ }
2705
+ viewMessage('');
2706
+ setStatus(`${activeTab()}-status`, `Image: ${src.title}, ${src.w0} x ${src.h0} px`
2707
+ + (src.levels.length > 1 ? `, ${src.levels.length} levels` : '') + (src.plain ? ' (no pixel access)' : ''));
2708
+ viewRedraw();
2709
+ } catch (err) {
2710
+ if (ctl.signal.aborted || err?.name === 'AbortError') return;
2711
+ V.failedSpec = spec;
2712
+ viewError(await explainError(spec.url, err));
2713
+ viewStatus();
2714
+ }
2715
+ }
2716
+
2717
+ const viewTabs = () => [$('vtab-map'), $('vtab-image'), $('vtab-settings')];
2718
+ function selectView(name) {
2719
+ for (const t of viewTabs()) {
2720
+ const on = t.dataset.view === name;
2721
+ t.setAttribute('aria-selected', String(on));
2722
+ t.tabIndex = on ? 0 : -1;
2723
+ }
2724
+ $('view-map').hidden = name !== 'map';
2725
+ $('view-image').hidden = $('view-zoom').hidden = name !== 'image';
2726
+ $('view-settings').hidden = name !== 'settings';
2727
+ $('workspace').classList.toggle('settings-on', name === 'settings');
2728
+ $('cursor').textContent = '';
2729
+ if (name === 'settings') settingsShown();
2730
+ if (name === 'image') {
2731
+ if (V.pending) { const p = V.pending; V.pending = null; viewProduct(p); }
2732
+ viewRedraw();
2733
+ }
2734
+ }
2735
+
2736
+ // A picked row / footprint: preview in its detail block, and the viewer follows if visible
2737
+ function selectProduct(block) {
2738
+ const prod = block._product;
2739
+ if (!prod) return;
2740
+ block._preview?._load?.();
2741
+ if (viewActive() === 'image') viewProduct(prod);
2742
+ else V.pending = prod;
2743
+ }
2744
+
2745
+ function initViewer() {
2746
+ const all = viewTabs();
2747
+ for (const t of all) {
2748
+ t.addEventListener('click', () => selectView(t.dataset.view));
2749
+ t.addEventListener('keydown', (e) => {
2750
+ const d = { ArrowRight: 1, ArrowLeft: -1 }[e.key];
2751
+ if (!d) return;
2752
+ const next = all[(all.indexOf(t) + d + all.length) % all.length];
2753
+ selectView(next.dataset.view);
2754
+ next.focus();
2755
+ });
2756
+ }
2757
+ $('view-zoom-in').addEventListener('click', () => viewZoom(2));
2758
+ $('view-zoom-out').addEventListener('click', () => viewZoom(0.5));
2759
+ $('view-1to1').addEventListener('click', () => viewSetScale(1 / dpr()));
2760
+ $('view-fit').addEventListener('click', () => viewFit('all'));
2761
+ $('view-fit-width').addEventListener('click', () => viewFit('width'));
2762
+ $('view-auto').addEventListener('click', () => {
2763
+ if (V.src) setStretch({ ...autoStretch(V.src.base?.stats, V.src.int), gamma: V.stretch.gamma });
2764
+ });
2765
+ for (const id of ['view-min', 'view-max', 'view-gamma']) {
2766
+ $(id).addEventListener('change', () => {
2767
+ const [min, max, gamma] = ['view-min', 'view-max', 'view-gamma'].map(num);
2768
+ if ([min, max, gamma].some((v) => v == null || !Number.isFinite(v)) || !(max > min) || !(gamma > 0)) {
2769
+ setStatus(`${activeTab()}-status`, 'Image: contrast needs Max > Min and Gamma > 0', true);
2770
+ return;
2771
+ }
2772
+ setStretch({ min, max, gamma });
2773
+ });
2774
+ }
2775
+ $('view-source').addEventListener('change', () => {
2776
+ const c = V.choices[Number($('view-source').value)];
2777
+ if (c) viewOpen(c);
2778
+ });
2779
+ $('view-open').addEventListener('click', () => $('view-file').click());
2780
+ $('view-file').addEventListener('change', (e) => {
2781
+ const file = e.target.files[0];
2782
+ if (!file) return;
2783
+ V.file = { label: file.name, file, kind: /\.(png|jpe?g)$/i.test(file.name) ? 'img' : 'tiff' };
2784
+ e.target.value = '';
2785
+ selectView('image');
2786
+ viewOpen(V.file);
2787
+ });
2788
+ const local = (e) => { const r = viewCanvas.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };
2789
+ viewCanvas.addEventListener('wheel', (e) => {
2790
+ e.preventDefault();
2791
+ viewZoom(Math.exp(-e.deltaY * (e.deltaMode ? 0.05 : 0.0015)), ...local(e));
2792
+ }, { passive: false });
2793
+ let drag = null;
2794
+ viewCanvas.addEventListener('pointerdown', (e) => {
2795
+ if (!V.view || e.button !== 0) return;
2796
+ viewCanvas.focus();
2797
+ drag = { x: e.clientX, y: e.clientY, cx: V.view.cx, cy: V.view.cy };
2798
+ try { viewCanvas.setPointerCapture(e.pointerId); } catch { /* synthetic event */ }
2799
+ viewCanvas.classList.add('dragging');
2800
+ });
2801
+ viewCanvas.addEventListener('pointermove', (e) => {
2802
+ const [mx, my] = local(e);
2803
+ V.hover = { mx, my };
2804
+ if (drag && V.view) {
2805
+ V.view.cx = drag.cx - (e.clientX - drag.x) / V.view.s;
2806
+ V.view.cy = drag.cy - (e.clientY - drag.y) / V.view.s;
2807
+ V.touched = true;
2808
+ viewRedraw();
2809
+ }
2810
+ viewReadout();
2811
+ });
2812
+ const endDrag = () => { drag = null; viewCanvas.classList.remove('dragging'); };
2813
+ viewCanvas.addEventListener('pointerup', endDrag);
2814
+ viewCanvas.addEventListener('pointercancel', endDrag);
2815
+ viewCanvas.addEventListener('pointerleave', () => { V.hover = null; clearTimeout(V.readoutTimer); if (!drag) $('cursor').textContent = ''; });
2816
+ viewCanvas.addEventListener('dblclick', (e) => viewZoom(2, ...local(e)));
2817
+ viewCanvas.addEventListener('keydown', (e) => {
2818
+ if (!V.view || e.ctrlKey || e.metaKey || e.altKey) return;
2819
+ const [cw, ch] = frameSize();
2820
+ const pan = { ArrowLeft: [-0.2, 0], ArrowRight: [0.2, 0], ArrowUp: [0, -0.2], ArrowDown: [0, 0.2],
2821
+ PageUp: [0, -0.9], PageDown: [0, 0.9] }[e.key];
2822
+ const act = { '+': () => viewZoom(2), '=': () => viewZoom(2), '-': () => viewZoom(0.5), '0': () => viewSetScale(1 / dpr()),
2823
+ f: () => viewFit('all'), F: () => viewFit('all'), w: () => viewFit('width'), W: () => viewFit('width') }[e.key];
2824
+ if (pan) {
2825
+ V.view.cx += (pan[0] * cw) / V.view.s;
2826
+ V.view.cy += (pan[1] * ch) / V.view.s;
2827
+ V.touched = true;
2828
+ viewRedraw();
2829
+ } else if (act) act();
2830
+ else return;
2831
+ e.preventDefault();
2832
+ });
2833
+ new ResizeObserver(() => viewRedraw()).observe($('view-frame'));
2834
+ }
2835
+
2836
+ // lunar serve extras: the PDS image relay and --images DIR; ?image=URL opens an image
2837
+ async function initImages() {
2838
+ try {
2839
+ const res = await fetch(new URL('api/config', window.location.href));
2840
+ if (res.ok && /json/.test(res.headers.get('content-type') ?? '')) serveConfig = { ...serveConfig, ...(await res.json()) };
2841
+ if (serveConfig.images) {
2842
+ const list = await fetch(new URL('api/images', window.location.href));
2843
+ if (list.ok) localImages = await list.json();
2844
+ }
2845
+ } catch { /* not lunar serve */ }
2846
+ fillChoices();
2847
+ const url = new URLSearchParams(window.location.search).get('image');
2848
+ if (url) {
2849
+ V.product = { id: fileName(url), sources: [{ label: fileName(url), url: new URL(url, window.location.href).href,
2850
+ kind: /\.(png|jpe?g)$/i.test(url) ? 'img' : 'tiff' }] };
2851
+ selectView('image');
2852
+ viewOpen(V.product.sources[0]);
2853
+ }
2854
+ }
2855
+
2856
+ // Settings (lunar serve / lunar app): /api/status, jobs over Server-Sent Events, job submission.
2857
+ // Admin (localhost or the --admin-token) may start jobs; the server enforces it, the page follows.
2858
+ const S = { on: false, admin: false, token: null, status: null, jobs: [], seen: {}, regions: null, es: null, timer: 0 };
2859
+
2860
+ function readToken() {
2861
+ const params = new URLSearchParams(window.location.search);
2862
+ let token = params.get('token');
2863
+ try {
2864
+ if (token) sessionStorage.setItem('lunar-token', token);
2865
+ else token = sessionStorage.getItem('lunar-token');
2866
+ } catch { /* storage blocked */ }
2867
+ if (params.has('token')) {
2868
+ params.delete('token');
2869
+ const q = params.toString();
2870
+ history.replaceState(null, '', window.location.pathname + (q ? `?${q}` : '') + window.location.hash);
2871
+ }
2872
+ return token;
2873
+ }
2874
+
2875
+ function apiUrl(path) {
2876
+ const u = new URL(path, window.location.href);
2877
+ u.search = '';
2878
+ if (S.token) u.searchParams.set('token', S.token);
2879
+ return u;
2880
+ }
2881
+
2882
+ async function api(path, { method = 'GET', json } = {}) {
2883
+ const headers = {};
2884
+ if (S.token) headers.Authorization = `Bearer ${S.token}`;
2885
+ if (json !== undefined) headers['Content-Type'] = 'application/json';
2886
+ const res = await fetch(new URL(path, window.location.href), {
2887
+ method, headers, body: json === undefined ? undefined : JSON.stringify(json), cache: 'no-store',
2888
+ });
2889
+ const data = /json/.test(res.headers.get('content-type') ?? '') ? await res.json() : null;
2890
+ if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
2891
+ return data;
2892
+ }
2893
+
2894
+ function fmtBytes(n) {
2895
+ if (n == null) return '';
2896
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
2897
+ let i = 0;
2898
+ while (n >= 1000 && i < units.length - 1) { n /= 1000; i++; }
2899
+ return `${i ? n.toFixed(n < 10 ? 2 : n < 100 ? 1 : 0) : n} ${units[i]}`;
2900
+ }
2901
+
2902
+ function fmtWhen(v) {
2903
+ if (v == null || v === '') return '';
2904
+ const d = typeof v === 'number' ? new Date(v * 1000) : new Date(v);
2905
+ if (Number.isNaN(d.getTime())) return String(v);
2906
+ const p2 = (x) => String(x).padStart(2, '0');
2907
+ return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())} ${p2(d.getHours())}:${p2(d.getMinutes())}`;
2908
+ }
2909
+
2910
+ function fmtDur(s) {
2911
+ if (s == null || !Number.isFinite(s)) return '';
2912
+ s = Math.max(0, Math.round(s));
2913
+ if (s < 60) return `${s} s`;
2914
+ if (s < 3600) return `${Math.floor(s / 60)} min ${s % 60} s`;
2915
+ return `${Math.floor(s / 3600)} h ${Math.floor((s % 3600) / 60)} min`;
2916
+ }
2917
+
2918
+ function grid(head, rows) {
2919
+ const nums = new Set(head.map((h, i) => (h.endsWith(' ') ? i : -1)));
2920
+ const thead = el('thead', {}, el('tr', {}, ...head.map((h, i) => el('th', nums.has(i) ? { class: 'num' } : {}, h.trim()))));
2921
+ const tbody = el('tbody', {}, ...rows.map((r) => (r instanceof Node ? r
2922
+ : el('tr', {}, ...r.map((c, i) => (c instanceof Node && c.tagName === 'TD' ? c : el('td', nums.has(i) ? { class: 'num' } : {}, c)))))));
2923
+ return el('table', { class: 'grid' }, thead, tbody);
2924
+ }
2925
+
2926
+ function progressBar(frac, { busy = false, error = false, text } = {}) {
2927
+ const pct = Math.round(Math.min(1, Math.max(0, frac || 0)) * 100);
2928
+ const bar = el('div', { class: 'bar' });
2929
+ bar.style.width = `${pct}%`;
2930
+ return el('div', { class: `progress${busy ? ' busy' : ''}${error ? ' error' : ''}`, role: 'progressbar',
2931
+ 'aria-valuenow': String(pct), 'aria-valuemin': '0', 'aria-valuemax': '100' }, bar, el('span', {}, text ?? `${pct}%`));
2932
+ }
2933
+
2934
+ function button(text, onclick, { title, disabled = false } = {}) {
2935
+ const b = el('button', { class: 'tool', type: 'button', ...(title ? { title } : {}) }, text);
2936
+ b.disabled = disabled || !S.admin;
2937
+ b.addEventListener('click', onclick);
2938
+ return b;
2939
+ }
2940
+
2941
+ async function startJob(body) {
2942
+ try {
2943
+ const job = await api('api/jobs', { method: 'POST', json: body });
2944
+ setStatus(`${activeTab()}-status`, `Started: ${job.title}`);
2945
+ S.jobs = [...S.jobs.filter((j) => j.id !== job.id), job];
2946
+ renderJobs();
2947
+ } catch (err) {
2948
+ setStatus(`${activeTab()}-status`, `Job not started: ${err.message}`, true);
2949
+ }
2950
+ }
2951
+
2952
+ function renderIndexes(st) {
2953
+ const data = st.data;
2954
+ $('set-data-dir').textContent = data.path ? `Data dir: ${data.path}${data.exists ? '' : ' (not created yet)'}` : 'Data dir (path hidden from read-only clients)';
2955
+ const rows = data.indexes.map((ix) => {
2956
+ const ds = ix.dataset;
2957
+ const build = button(ix.exists ? 'Rebuild' : 'Build', () => {
2958
+ if (ix.exists && !window.confirm(`Rebuild the ${ds.toUpperCase()} index? The current Parquet is replaced when the build finishes.`)) return;
2959
+ const release = $('set-release').value.trim();
2960
+ startJob({ kind: 'build-db', dataset: ds, force: ix.exists, volume_only: $('set-volume-only').checked,
2961
+ ...(release ? { release } : {}) });
2962
+ });
2963
+ const update = button('Update', () => startJob({ kind: 'build-db', dataset: ds, update: true }),
2964
+ { disabled: !ix.exists, title: 'Append volumes released since the build' });
2965
+ return [
2966
+ ds.toUpperCase(),
2967
+ ix.exists ? el('span', { class: 'ok' }, 'Built') : el('span', { class: 'dim' }, 'Missing'),
2968
+ ix.rows != null ? Number(ix.rows).toLocaleString('en-US') : '',
2969
+ el('td', { title: ix.source === 'volume_index' ? 'built from one volume index' : ix.source ? 'cumulative index' : '' },
2970
+ `${ix.release ?? ''}${ix.source === 'volume_index' ? ' (vol)' : ''}`),
2971
+ fmtBytes(ix.size),
2972
+ el('td', { title: ix.built_at ? `built ${fmtWhen(ix.built_at)}` : '' }, fmtWhen(ix.updated_at ?? ix.built_at)),
2973
+ el('td', { class: 'actions' }, build, update),
2974
+ ];
2975
+ });
2976
+ $('set-indexes').replaceChildren(grid(['Index', 'Status', 'Rows ', 'Release', 'Size ', 'Updated', ''], rows));
2977
+ }
2978
+
2979
+ function renderReference(st) {
2980
+ const ref = st.reference;
2981
+ const rows = [
2982
+ ...ref.pgda.map((s) => {
2983
+ const kinds = Object.entries(s.kinds).map(([k, n]) => (n > 1 ? `${k} x${n}` : k)).join(', ');
2984
+ return ['PGDA', s.site, s.region ?? '', el('td', { class: 'wrap' }, kinds || '(none)'), fmtBytes(s.size)];
2985
+ }),
2986
+ ...ref.sdem.map((z) => ['SfS DEM', z.zip, '', el('td', { class: 'wrap', title: z.members.join('\n') },
2987
+ `${z.count} member${z.count === 1 ? '' : 's'}`), fmtBytes(z.size)]),
2988
+ ];
2989
+ $('set-reference').replaceChildren(...(rows.length ? [grid(['Source', 'Site / zip', 'Region', 'Downloaded', 'Size '], rows)] : []));
2990
+ $('set-ref-dir').textContent = (rows.length ? '' : 'No reference data downloaded yet. ')
2991
+ + (ref.path ? `Reference cache: ${ref.path}` : '');
2992
+ }
2993
+
2994
+ function renderDownloads(st) {
2995
+ const dl = st.downloads;
2996
+ if (dl.path) $('set-dl-dest').placeholder = dl.path;
2997
+ const rows = dl.files.map((f) => [f.name, f.partial ? el('span', { class: 'dim' }, 'partial') : 'complete', fmtBytes(f.size), fmtWhen(f.mtime)]);
2998
+ $('set-downloads').replaceChildren(...(rows.length ? [grid([`Downloaded (${dl.count}, ${fmtBytes(dl.size)})`, 'State', 'Size ', 'Modified'], rows)] : []));
2999
+ const c = st.cache;
3000
+ $('set-cache-dir').textContent = `Cache${c.path ? `: ${c.path}` : ''}, ${fmtBytes(c.size)}${c.exists ? '' : ' (empty)'}`;
3001
+ const crows = c.items.map((i) => [i.name, i.dir ? `${i.files}${i.truncated ? '+' : ''}` : '', fmtBytes(i.size), fmtWhen(i.mtime)]);
3002
+ $('set-cache').replaceChildren(...(crows.length ? [grid(['Cache entry', 'Files ', 'Size ', 'Modified'], crows)] : []));
3003
+ }
3004
+
3005
+ function renderRuns(st) {
3006
+ const r = st.runs;
3007
+ if (!r.configured) {
3008
+ $('set-runs-dir').textContent = 'No runs dir: set LUNAR_RUNS_DIR or start with --runs-dir DIR to list SfS runs.';
3009
+ $('set-runs').replaceChildren();
3010
+ return;
3011
+ }
3012
+ $('set-runs-dir').textContent = `${r.path ? `Runs dir: ${r.path}` : 'Runs dir'}${r.exists === false ? ' (missing)' : ''}, ${r.runs.length} run(s) with provenance.json`;
3013
+ const rows = r.runs.map((run) => {
3014
+ const ok = run.steps.filter((s) => s.status === 'ok').length;
3015
+ const last = run.steps[run.steps.length - 1];
3016
+ const steps = run.steps.length ? `${ok} of ${run.steps.length} ok${last && last.status !== 'ok' ? `, ${last.name}: ${last.status}` : ''}` : '';
3017
+ const cls = run.status === 'ok' ? 'ok' : run.status === 'error' ? 'bad' : '';
3018
+ return [
3019
+ run.name,
3020
+ el('span', { class: cls }, run.status ?? ''),
3021
+ fmtWhen(run.started),
3022
+ fmtWhen(run.finished),
3023
+ el('td', { class: 'wrap', title: run.steps.map((s) => `${s.name}: ${s.status}${s.seconds != null ? ` (${fmtDur(s.seconds)})` : ''}`).join('\n') }, steps),
3024
+ run.report && S.admin ? link(apiUrl(`runs/${encodeURIComponent(run.name)}/report.html`).href, 'report.html') : '',
3025
+ ];
3026
+ });
3027
+ $('set-runs').replaceChildren(...(rows.length ? [grid(['Run', 'Status', 'Started', 'Finished', 'Steps', 'Report'], rows)] : []));
3028
+ }
3029
+
3030
+ function renderEnv(st) {
3031
+ const env = st.environment;
3032
+ const rows = [];
3033
+ for (const r of env.roots) rows.push([r.name, r.path ?? (r.set ? 'set' : el('span', { class: 'dim' }, 'not set'))]);
3034
+ const v = env.versions ?? {};
3035
+ for (const [k, label] of [['asp', 'ASP'], ['isis', 'ISIS'], ['ale', 'ALE'], ['gdal', 'GDAL']]) {
3036
+ if (k in v) rows.push([label, v[k] ?? el('span', { class: 'dim' }, 'not found')]);
3037
+ }
3038
+ const missing = Object.entries(env.tools).filter(([, t]) => !t.found).map(([n]) => n);
3039
+ rows.push(['Tools', missing.length ? el('span', { class: missing.length === Object.keys(env.tools).length ? 'bad' : '' },
3040
+ `missing: ${missing.join(' ')}`) : 'all found']);
3041
+ const subs = Object.entries(env.isisdata);
3042
+ if (subs.length) rows.push(['ISISDATA', subs.map(([k, on]) => `${k}: ${on ? 'yes' : 'no'}`).join(', ')]);
3043
+ for (const c of st.cooldowns) rows.push(['Host paused', `${c.host} until ${c.until_text} (${fmtDur(c.seconds)})`]);
3044
+ if (!st.cooldowns.length) rows.push(['Host pauses', 'none']);
3045
+ for (const d of st.disks) rows.push([`Disk (${d.labels.join(', ')})`, `${fmtBytes(d.free)} free of ${fmtBytes(d.total)}`]);
3046
+ rows.push(['Toolkit', st.version]);
3047
+ $('set-env').replaceChildren(grid(['Item', 'Value'], rows.map(([k, val]) => [k, el('td', { class: 'wrap' }, val)])));
3048
+ }
3049
+
3050
+ function fillRegions() {
3051
+ if (!S.regions) return;
3052
+ const sel = $('set-region');
3053
+ const current = sel.value;
3054
+ sel.replaceChildren(...S.regions.regions.map((r) => el('option', { value: String(r.id) },
3055
+ `${r.id} ${r.name}${r.pgda_site ? ` (${r.pgda_site})` : ' (no PGDA site)'}, zip ${fmtBytes(r.zenodo_size)}`)));
3056
+ sel.value = current || '11';
3057
+ const box = $('set-pgda-kinds');
3058
+ if (!box.children.length) {
3059
+ for (const k of S.regions.pgda_kinds) {
3060
+ const cb = el('input', { type: 'checkbox', value: k, 'data-admin': '' });
3061
+ cb.checked = k === 'surf';
3062
+ box.append(el('label', { title: k === 'clones' ? '100 clone DEMs' : '' }, cb, k));
3063
+ }
3064
+ }
3065
+ }
3066
+
3067
+ function applyAdmin() {
3068
+ for (const node of document.querySelectorAll('#settings [data-admin]')) node.disabled = !S.admin;
3069
+ const note = $('set-note');
3070
+ note.hidden = S.admin;
3071
+ note.textContent = 'Read-only: this page is served to the network, so it shows status only. Building indexes, '
3072
+ + 'downloads and local paths need this machine (localhost) or the server\'s admin token (open the page with ?token=TOKEN).';
3073
+ }
3074
+
3075
+ function renderStatus() {
3076
+ const st = S.status;
3077
+ if (!st) return;
3078
+ renderIndexes(st);
3079
+ renderReference(st);
3080
+ renderDownloads(st);
3081
+ renderRuns(st);
3082
+ renderEnv(st);
3083
+ $('set-info').textContent = `${S.admin ? 'Admin' : 'Read-only'} | read ${fmtWhen(st.built)}`;
3084
+ }
3085
+
3086
+ async function loadStatus(refresh = false) {
3087
+ if (!S.on) return;
3088
+ try {
3089
+ S.status = await api(`api/status${refresh ? '?refresh=1' : ''}`);
3090
+ S.admin = S.status.admin;
3091
+ applyAdmin();
3092
+ renderStatus();
3093
+ } catch (err) {
3094
+ $('set-info').textContent = `Status failed: ${err.message}`;
3095
+ }
3096
+ }
3097
+
3098
+ function jobRow(j) {
3099
+ const active = j.status === 'running' || j.status === 'queued';
3100
+ const frac = j.status === 'done' ? 1 : j.progress;
3101
+ const text = j.status === 'queued' ? 'queued' : j.busy ? j.stage : undefined;
3102
+ const bar = progressBar(frac, { busy: j.busy && active, error: j.status === 'error', text });
3103
+ const file = j.file ? `${j.file_count > 1 ? `${j.file_index}/${j.file_count} ` : ''}${j.file}` : '';
3104
+ const bytes = j.bytes_total ? `${fmtBytes(j.bytes_done)} of ${fmtBytes(j.bytes_total)}` : j.bytes_done ? fmtBytes(j.bytes_done) : '';
3105
+ const cancel = button('Cancel', () => api(`api/jobs/${j.id}`, { method: 'DELETE' })
3106
+ .catch((err) => setStatus(`${activeTab()}-status`, `Cancel failed: ${err.message}`, true)),
3107
+ { disabled: !active || !j.cancellable, title: j.cancellable ? 'Stop after the current chunk (the .part file is kept)' : 'The DuckDB step cannot be interrupted' });
3108
+ const cls = j.status === 'done' ? 'ok' : j.status === 'error' ? 'bad' : '';
3109
+ const row = el('tr', {}, el('td', { class: 'wrap' }, j.title), el('td', {}, el('span', { class: cls }, j.status)),
3110
+ el('td', {}, bar), el('td', { class: 'wrap' }, j.status === 'running' ? j.stage : j.error ?? ''),
3111
+ el('td', { class: 'mono' }, file), el('td', { class: 'num' }, bytes),
3112
+ el('td', { class: 'num' }, active && j.rate ? `${fmtBytes(j.rate)}/s` : ''),
3113
+ el('td', { class: 'num' }, active ? fmtDur(j.eta) : j.finished && j.started ? fmtDur(j.finished - j.started) : ''),
3114
+ el('td', { class: 'actions' }, cancel));
3115
+ const rows = [row];
3116
+ if (j.log?.length) {
3117
+ rows.push(el('tr', {}, el('td', { colspan: '9' }, el('pre', { class: 'log' }, j.log.slice(-4).join('\n')))));
3118
+ }
3119
+ return rows;
3120
+ }
3121
+
3122
+ function renderJobs() {
3123
+ const jobs = [...S.jobs].sort((a, b) => b.created - a.created);
3124
+ $('set-jobs-empty').hidden = jobs.length > 0;
3125
+ $('set-jobs').replaceChildren(...(jobs.length ? [grid(['Job', 'Status', 'Progress', 'Stage', 'File', 'Bytes ', 'Rate ', 'ETA / took ', ''],
3126
+ jobs.flatMap(jobRow))] : []));
3127
+ const active = jobs.filter((j) => j.status === 'running' || j.status === 'queued');
3128
+ const summary = $('jobs-summary');
3129
+ summary.hidden = !active.length;
3130
+ if (active.length) {
3131
+ const running = active.filter((j) => j.status === 'running');
3132
+ const frac = active.reduce((a, j) => a + (j.progress || 0), 0) / active.length;
3133
+ const what = running.length === active.length ? `${running.length} job${running.length === 1 ? '' : 's'} running`
3134
+ : `${active.length} jobs (${running.length} running)`;
3135
+ summary.textContent = `${what}, ${Math.round(frac * 100)}%`;
3136
+ }
3137
+ }
3138
+
3139
+ // A finished build reloads the index sources; any finished job refreshes the status
3140
+ async function jobsChanged(jobs, first) {
3141
+ S.jobs = jobs;
3142
+ renderJobs();
3143
+ let rebuilt = false, finished = false;
3144
+ for (const j of jobs) {
3145
+ const was = S.seen[j.id];
3146
+ S.seen[j.id] = j.status;
3147
+ if (first || was === j.status || !['done', 'error', 'cancelled'].includes(j.status)) continue;
3148
+ finished = true;
3149
+ if (j.kind === 'build-db' && j.status === 'done') rebuilt = true;
3150
+ setStatus(`${activeTab()}-status`, `${j.title}: ${j.status}${j.error ? ` (${j.error})` : ''}`, j.status === 'error');
3151
+ }
3152
+ if (rebuilt && db) {
3153
+ for (const ds of DATASETS) if (sources[ds]?.label === 'lunar serve') delete sources[ds];
3154
+ for (const k of Object.keys(columnCache)) delete columnCache[k];
3155
+ await findSources(true);
3156
+ renderSources();
3157
+ }
3158
+ if (finished) loadStatus();
3159
+ }
3160
+
3161
+ function watchJobs() {
3162
+ if (S.es || typeof EventSource === 'undefined') return;
3163
+ let first = true;
3164
+ const es = new EventSource(apiUrl('api/jobs/events'));
3165
+ es.addEventListener('jobs', (e) => { jobsChanged(JSON.parse(e.data), first); first = false; });
3166
+ es.onerror = () => { // server gone or restarted: retry slowly
3167
+ es.close();
3168
+ S.es = null;
3169
+ clearTimeout(S.timer);
3170
+ S.timer = setTimeout(watchJobs, 5000);
3171
+ };
3172
+ S.es = es;
3173
+ }
3174
+
3175
+ function settingsShown() {
3176
+ if (!S.on) return;
3177
+ loadStatus();
3178
+ }
3179
+
3180
+ function selectedKinds() {
3181
+ return [...$('set-pgda-kinds').querySelectorAll('input:checked')].map((c) => c.value);
3182
+ }
3183
+
3184
+ function initSettingsControls() {
3185
+ $('set-refresh').addEventListener('click', () => loadStatus(true));
3186
+ $('jobs-summary').addEventListener('click', () => selectView('settings'));
3187
+ $('set-pgda-btn').addEventListener('click', () => {
3188
+ const kinds = selectedKinds();
3189
+ if (!kinds.length) return setStatus(`${activeTab()}-status`, 'Choose at least one PGDA kind.', true);
3190
+ startJob({ kind: 'pgda', region: Number($('set-region').value), kinds });
3191
+ });
3192
+ $('set-sdem-list').addEventListener('click', async () => {
3193
+ const sel = $('set-sdem-members');
3194
+ $('set-sdem-list').disabled = true;
3195
+ setStatus(`${activeTab()}-status`, 'Reading the zip directory...');
3196
+ try {
3197
+ const r = await api(`api/sdem/members?region=${encodeURIComponent($('set-region').value)}`);
3198
+ sel.replaceChildren(...r.members.map((m) => el('option', { value: m.name.split('/').pop() }, `${m.name.split('/').slice(1).join('/') || m.name} (${fmtBytes(m.size)})`)));
3199
+ sel.hidden = false;
3200
+ setStatus(`${activeTab()}-status`, `${r.zip}: ${r.members.length} members; select some, or use the patterns`);
3201
+ } catch (err) {
3202
+ setStatus(`${activeTab()}-status`, `Zip directory: ${err.message}`, true);
3203
+ } finally {
3204
+ $('set-sdem-list').disabled = !S.admin;
3205
+ }
3206
+ });
3207
+ $('set-region').addEventListener('change', () => { $('set-sdem-members').hidden = true; $('set-sdem-members').replaceChildren(); });
3208
+ $('set-sdem-btn').addEventListener('click', () => {
3209
+ const picked = [...$('set-sdem-members').selectedOptions].map((o) => o.value);
3210
+ const members = picked.length ? picked : $('set-sdem-pat').value.split(/[\s,]+/).filter(Boolean);
3211
+ if (!members.length) return setStatus(`${activeTab()}-status`, 'Enter member patterns or pick members.', true);
3212
+ startJob({ kind: 'sdem', region: Number($('set-region').value), members });
3213
+ });
3214
+ $('set-dl-btn').addEventListener('click', () => {
3215
+ const ids = $('set-dl-ids').value.split(/[\s,;]+/).map((s) => s.trim().toUpperCase()).filter(Boolean);
3216
+ if (!ids.length) return setStatus(`${activeTab()}-status`, 'Enter one or more product IDs to download.', true);
3217
+ const dest = $('set-dl-dest').value.trim();
3218
+ startJob({ kind: 'download', ids, product: $('set-dl-product').value, ...(dest ? { dest } : {}) });
3219
+ });
3220
+ }
3221
+
3222
+ async function initSettings() {
3223
+ S.token = readToken();
3224
+ initSettingsControls();
3225
+ applyAdmin();
3226
+ try {
3227
+ const cfg = await api('api/config');
3228
+ if (!cfg?.jobs) throw new Error('not lunar serve');
3229
+ S.on = true;
3230
+ S.admin = !!cfg.admin;
3231
+ S.regions = await api('api/regions');
3232
+ fillRegions();
3233
+ applyAdmin();
3234
+ $('set-info').textContent = S.admin ? 'Admin' : 'Read-only';
3235
+ watchJobs();
3236
+ if (!$('view-settings').hidden) loadStatus();
3237
+ } catch {
3238
+ for (const node of document.querySelectorAll('#settings [data-admin], #set-refresh')) node.disabled = true;
3239
+ $('set-note').hidden = false;
3240
+ $('set-note').textContent = 'Settings need the local server: start lunar app or lunar serve.';
3241
+ }
3242
+ }
3243
+
3244
+ // Boot
3245
+ fillOdeSelects();
3246
+ initViewer();
3247
+ initImages();
3248
+ initSettings();
3249
+ try {
3250
+ await initDB();
3251
+ await findSources();
3252
+ renderSources();
3253
+ setStatus('index-status', 'Ready');
3254
+ } catch (err) {
3255
+ setStatus('index-status', `Failed to initialize DuckDB: ${err.message}`, true);
3256
+ }
3257
+ </script>
3258
+ </body>
3259
+ </html>