|
3 | 3 | <head> |
4 | 4 | <meta charset="UTF-8" /> |
5 | 5 | <meta name="viewport" content="width=device-width" /> |
6 | | - <title>NASA Sky Viewer</title> |
| 6 | + <title>NASA FITS Explorer</title> |
7 | 7 | <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%230f172a'/%3E%3Cpath fill='%23facc15' d='M16 4l3.4 7 7.6.7-5.8 5.2 1.8 7.6-7-4.2-7 4.2 1.8-7.6-5.8-5.2 7.6-.7z'/%3E%3C/svg%3E" /> |
8 | 8 | <link rel="stylesheet" href="/static/styles.css" /> |
9 | 9 | <link rel="stylesheet" href="https://aladin.cds.unistra.fr/AladinLite/api/v3/latest/aladin.min.css" /> |
10 | 10 | </head> |
11 | 11 | <body> |
12 | | - <div id="aladin-lite-div"></div> |
| 12 | + <div id="aladin-lite-div" aria-label="Interactive FITS viewer"></div> |
13 | 13 | <div id="aladin-overlay" class="overlay" role="status" aria-live="polite"> |
14 | | - Loading the NASA sky map… |
| 14 | + <span id="overlay-message">Loading the NASA sky map…</span> |
15 | 15 | </div> |
16 | | - <div class="hud" aria-live="polite"> |
17 | | - <div class="status" id="status-message"> |
18 | | - Target: NGC 2175 · Survey: WISE (Infrared) |
19 | | - </div> |
| 16 | + <aside class="hud" aria-live="polite"> |
| 17 | + <p class="title">NASA FITS Explorer</p> |
| 18 | + <p class="status" id="status-message">Select a FITS source to start exploring.</p> |
20 | 19 | <div class="controls"> |
21 | | - <button type="button" id="reset-view">Reset view</button> |
22 | | - <select id="survey-select" aria-label="Select survey layer"> |
23 | | - <option value="P/allWISE/color" selected>WISE (Infrared)</option> |
24 | | - <option value="P/DSS2/color">DSS2 (Optical)</option> |
25 | | - <option value="P/2MASS/color">2MASS (Near Infrared)</option> |
26 | | - <option value="P/GALEXGR6/AIS/color">GALEX (Ultraviolet)</option> |
27 | | - </select> |
| 20 | + <label class="file-picker"> |
| 21 | + <input id="fits-file" type="file" accept=".fits,.fit,application/fits" /> |
| 22 | + <span>Open local FITS file</span> |
| 23 | + </label> |
| 24 | + <div class="url-loader"> |
| 25 | + <input id="fits-url" type="url" placeholder="https://data.nasa.gov/sample.fits" aria-label="FITS file URL" /> |
| 26 | + <button type="button" id="load-url">Load URL</button> |
| 27 | + </div> |
28 | 28 | </div> |
29 | | - <div class="notice hidden" id="aladin-error" role="alert"></div> |
30 | | - </div> |
| 29 | + <div class="samples"> |
| 30 | + <span class="samples-label">Try a sample dataset:</span> |
| 31 | + <div class="sample-buttons"> |
| 32 | + <button |
| 33 | + type="button" |
| 34 | + data-fits-url="https://cdsarc.cds.unistra.fr/saadavizier/download?oid=864972989978905533" |
| 35 | + data-label="WISE Infrared — IC 434 & Horsehead Nebula" |
| 36 | + data-colormap="magma" |
| 37 | + > |
| 38 | + Horsehead Nebula (WISE) |
| 39 | + </button> |
| 40 | + <button |
| 41 | + type="button" |
| 42 | + data-fits-url="https://fits.gsfc.nasa.gov/samples/NGC6543.fits" |
| 43 | + data-label="HST WFPC2 — NGC 6543 (Cat's Eye)" |
| 44 | + data-colormap="viridis" |
| 45 | + > |
| 46 | + Cat's Eye Nebula (HST) |
| 47 | + </button> |
| 48 | + <button |
| 49 | + type="button" |
| 50 | + data-fits-url="https://fits.gsfc.nasa.gov/samples/FUV.fits" |
| 51 | + data-label="GALEX FUV — M101 Spiral" |
| 52 | + data-colormap="plasma" |
| 53 | + > |
| 54 | + Pinwheel Galaxy (GALEX) |
| 55 | + </button> |
| 56 | + </div> |
| 57 | + </div> |
| 58 | + <p class="notice" id="aladin-hint"> |
| 59 | + Upload high-resolution FITS files from NASA missions or paste a link to remote data sets. Large imagery may take a moment to render. |
| 60 | + </p> |
| 61 | + <p class="notice hidden" id="aladin-error" role="alert"></p> |
| 62 | + </aside> |
31 | 63 | <script type="module"> |
32 | 64 | import { A } from 'https://aladin.cds.unistra.fr/AladinLite/api/v3/latest/aladin.min.js'; |
33 | 65 |
|
34 | 66 | const overlay = document.getElementById('aladin-overlay'); |
| 67 | + const overlayMessage = document.getElementById('overlay-message'); |
35 | 68 | const statusMessage = document.getElementById('status-message'); |
36 | 69 | const errorBox = document.getElementById('aladin-error'); |
37 | | - const surveySelect = document.getElementById('survey-select'); |
38 | | - const resetButton = document.getElementById('reset-view'); |
39 | | - |
40 | | - const defaultConfig = { |
41 | | - survey: 'P/allWISE/color', |
42 | | - projection: 'AIT', |
43 | | - fov: 1.5, |
44 | | - target: 'NGC 2175', |
45 | | - cooFrame: 'galactic', |
46 | | - showCooGrid: true, |
47 | | - fullScreen: true |
48 | | - }; |
| 70 | + const fileInput = document.getElementById('fits-file'); |
| 71 | + const urlInput = document.getElementById('fits-url'); |
| 72 | + const loadUrlButton = document.getElementById('load-url'); |
| 73 | + const sampleButtons = Array.from(document.querySelectorAll('[data-fits-url]')); |
49 | 74 |
|
50 | 75 | let aladinInstance = null; |
| 76 | + let currentRequestId = 0; |
51 | 77 |
|
52 | | - function updateStatus(target, surveyLabel) { |
53 | | - statusMessage.textContent = `Target: ${target} · Survey: ${surveyLabel}`; |
54 | | - } |
| 78 | + const DEFAULT_SAMPLE = sampleButtons.length |
| 79 | + ? { |
| 80 | + url: sampleButtons[0].dataset.fitsUrl, |
| 81 | + label: sampleButtons[0].dataset.label, |
| 82 | + colormap: sampleButtons[0].dataset.colormap |
| 83 | + } |
| 84 | + : null; |
55 | 85 |
|
56 | | - function handleError(error) { |
57 | | - console.error('Failed to initialise Aladin Lite', error); |
| 86 | + function setOverlay(message) { |
| 87 | + overlayMessage.textContent = message; |
58 | 88 | overlay.classList.remove('hidden'); |
59 | | - overlay.textContent = 'Unable to load the NASA sky map right now.'; |
60 | | - errorBox.classList.remove('hidden'); |
61 | | - errorBox.textContent = error instanceof Error ? error.message : String(error); |
62 | 89 | } |
63 | 90 |
|
64 | | - try { |
65 | | - await A.init; |
66 | | - aladinInstance = await A.aladin('#aladin-lite-div', defaultConfig); |
| 91 | + function clearOverlay() { |
67 | 92 | overlay.classList.add('hidden'); |
| 93 | + overlayMessage.textContent = 'Loading the NASA sky map…'; |
| 94 | + } |
| 95 | + |
| 96 | + function showError(message) { |
| 97 | + errorBox.textContent = message; |
| 98 | + errorBox.classList.remove('hidden'); |
| 99 | + } |
| 100 | + |
| 101 | + function clearError() { |
| 102 | + errorBox.textContent = ''; |
68 | 103 | errorBox.classList.add('hidden'); |
69 | | - updateStatus(defaultConfig.target, 'WISE (Infrared)'); |
| 104 | + } |
70 | 105 |
|
71 | | - resetButton.addEventListener('click', () => { |
72 | | - if (!aladinInstance) { |
73 | | - return; |
74 | | - } |
| 106 | + function updateStatus(label, fov) { |
| 107 | + const parts = []; |
| 108 | + if (label) { |
| 109 | + parts.push(`Source: ${label}`); |
| 110 | + } |
| 111 | + if (Number.isFinite(fov)) { |
| 112 | + const span = Math.max(0.01, fov * 2); |
| 113 | + parts.push(`FoV ≈ ${span.toFixed(2)}°`); |
| 114 | + } |
| 115 | + statusMessage.textContent = parts.join(' · ') || 'Ready to load a FITS image.'; |
| 116 | + } |
| 117 | + |
| 118 | + function focusOnImage(ra, dec, fov) { |
| 119 | + if (Number.isFinite(ra) && Number.isFinite(dec)) { |
| 120 | + aladinInstance.gotoRaDec(ra, dec); |
| 121 | + } |
| 122 | + if (Number.isFinite(fov)) { |
| 123 | + const clamped = Math.min(60, Math.max(0.01, fov * 2)); |
| 124 | + aladinInstance.setFoV(clamped); |
| 125 | + } |
| 126 | + } |
| 127 | + |
| 128 | + function configureImage(image, colormap) { |
| 129 | + if (!image) { |
| 130 | + return; |
| 131 | + } |
| 132 | + try { |
| 133 | + image.setColormap(colormap || 'magma', { stretch: 'sqrt' }); |
| 134 | + } catch (error) { |
| 135 | + console.warn('Unable to update the FITS colormap', error); |
| 136 | + } |
| 137 | + } |
| 138 | + |
| 139 | + function deriveLabel(source) { |
| 140 | + if (typeof source === 'string') { |
75 | 141 | try { |
76 | | - aladinInstance.gotoObject(defaultConfig.target); |
77 | | - aladinInstance.setFov(defaultConfig.fov); |
78 | | - aladinInstance.setImageSurvey(defaultConfig.survey); |
79 | | - surveySelect.value = defaultConfig.survey; |
80 | | - updateStatus(defaultConfig.target, 'WISE (Infrared)'); |
81 | | - } catch (error) { |
82 | | - handleError(error); |
| 142 | + const url = new URL(source); |
| 143 | + return url.hostname; |
| 144 | + } catch { |
| 145 | + return source; |
83 | 146 | } |
84 | | - }); |
| 147 | + } |
| 148 | + if (source && typeof source.name === 'string') { |
| 149 | + return `Local file: ${source.name}`; |
| 150 | + } |
| 151 | + return 'Custom FITS'; |
| 152 | + } |
85 | 153 |
|
86 | | - surveySelect.addEventListener('change', (event) => { |
87 | | - if (!aladinInstance) { |
| 154 | + function loadFits(source, options = {}) { |
| 155 | + if (!aladinInstance || !source) { |
| 156 | + return; |
| 157 | + } |
| 158 | + |
| 159 | + const requestId = ++currentRequestId; |
| 160 | + const label = options.label || deriveLabel(source); |
| 161 | + |
| 162 | + clearError(); |
| 163 | + setOverlay(`Loading ${label}…`); |
| 164 | + |
| 165 | + const handleSuccess = (ra, dec, fov, image) => { |
| 166 | + if (requestId !== currentRequestId) { |
88 | 167 | return; |
89 | 168 | } |
90 | | - try { |
91 | | - const select = event.target; |
92 | | - const surveyId = select.value; |
93 | | - const label = select.options[select.selectedIndex].text; |
94 | | - aladinInstance.setImageSurvey(surveyId); |
95 | | - updateStatus(defaultConfig.target, label); |
96 | | - } catch (error) { |
97 | | - handleError(error); |
| 169 | + configureImage(image, options.colormap); |
| 170 | + focusOnImage(ra, dec, fov); |
| 171 | + updateStatus(label, fov); |
| 172 | + clearOverlay(); |
| 173 | + }; |
| 174 | + |
| 175 | + const handleError = (error) => { |
| 176 | + if (requestId !== currentRequestId) { |
| 177 | + return; |
98 | 178 | } |
| 179 | + console.error('Failed to load FITS data', error); |
| 180 | + clearOverlay(); |
| 181 | + showError('Unable to load the FITS data. Please verify the file or URL and try again.'); |
| 182 | + }; |
| 183 | + |
| 184 | + try { |
| 185 | + const result = aladinInstance.displayFITS(source, options.params || {}, handleSuccess, handleError); |
| 186 | + if (result && typeof result.then === 'function') { |
| 187 | + result.catch(handleError); |
| 188 | + } |
| 189 | + } catch (error) { |
| 190 | + handleError(error); |
| 191 | + } |
| 192 | + } |
| 193 | + |
| 194 | + function handleFileSelection(event) { |
| 195 | + const [file] = event.target.files || []; |
| 196 | + if (!file) { |
| 197 | + return; |
| 198 | + } |
| 199 | + loadFits(file, { label: `Local file: ${file.name}` }); |
| 200 | + event.target.value = ''; |
| 201 | + } |
| 202 | + |
| 203 | + function handleUrlLoad() { |
| 204 | + const url = urlInput.value.trim(); |
| 205 | + if (!url) { |
| 206 | + showError('Please enter a FITS file URL.'); |
| 207 | + return; |
| 208 | + } |
| 209 | + try { |
| 210 | + new URL(url); |
| 211 | + } catch { |
| 212 | + showError('The URL appears to be invalid. Please double-check and try again.'); |
| 213 | + return; |
| 214 | + } |
| 215 | + loadFits(url, { label: url }); |
| 216 | + } |
| 217 | + |
| 218 | + function initialiseSampleButtons() { |
| 219 | + sampleButtons.forEach((button) => { |
| 220 | + button.addEventListener('click', () => { |
| 221 | + const { fitsUrl, label, colormap } = button.dataset; |
| 222 | + loadFits(fitsUrl, { label, colormap }); |
| 223 | + }); |
99 | 224 | }); |
100 | | - } catch (error) { |
101 | | - handleError(error); |
102 | 225 | } |
| 226 | + |
| 227 | + async function initialiseViewer() { |
| 228 | + try { |
| 229 | + await A.init; |
| 230 | + aladinInstance = await A.aladin('#aladin-lite-div', { |
| 231 | + cooFrame: 'icrs', |
| 232 | + projection: 'AIT', |
| 233 | + showCooGrid: false, |
| 234 | + fullScreen: true, |
| 235 | + fov: 5, |
| 236 | + target: 'M 31' |
| 237 | + }); |
| 238 | + |
| 239 | + initialiseSampleButtons(); |
| 240 | + if (DEFAULT_SAMPLE) { |
| 241 | + loadFits(DEFAULT_SAMPLE.url, DEFAULT_SAMPLE); |
| 242 | + } else { |
| 243 | + clearOverlay(); |
| 244 | + updateStatus(null); |
| 245 | + } |
| 246 | + } catch (error) { |
| 247 | + console.error('Aladin Lite failed to initialise', error); |
| 248 | + showError('The Aladin Lite viewer could not be initialised. Please refresh the page.'); |
| 249 | + clearOverlay(); |
| 250 | + } |
| 251 | + } |
| 252 | + |
| 253 | + fileInput.addEventListener('change', handleFileSelection); |
| 254 | + fileInput.addEventListener('click', () => { |
| 255 | + clearError(); |
| 256 | + }); |
| 257 | + |
| 258 | + loadUrlButton.addEventListener('click', handleUrlLoad); |
| 259 | + urlInput.addEventListener('keydown', (event) => { |
| 260 | + if (event.key === 'Enter') { |
| 261 | + event.preventDefault(); |
| 262 | + handleUrlLoad(); |
| 263 | + } |
| 264 | + }); |
| 265 | + |
| 266 | + initialiseViewer(); |
103 | 267 | </script> |
104 | 268 | </body> |
105 | 269 | </html> |
0 commit comments