|
23 | 23 | <input id="fits-url" type="url" placeholder="https://data.nasa.gov/sample.fits" aria-label="FITS file URL" /> |
24 | 24 | <button type="button" id="load-url">Load URL</button> |
25 | 25 | </div> |
| 26 | + <button type="button" id="add-marker" class="marker-control">Add marker</button> |
26 | 27 | </div> |
27 | 28 | <div class="samples"> |
28 | 29 | <span class="samples-label">Try a sample dataset:</span> |
|
58 | 59 | </p> |
59 | 60 | <p class="notice hidden" id="aladin-error" role="alert"></p> |
60 | 61 | </aside> |
| 62 | + <div id="marker-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="marker-modal-title"> |
| 63 | + <form class="modal__content"> |
| 64 | + <h2 id="marker-modal-title">Add marker</h2> |
| 65 | + <p class="modal__hint">Customize the marker details before saving it to the sky map.</p> |
| 66 | + <label for="marker-title"> |
| 67 | + <span>Title</span> |
| 68 | + <input id="marker-title" name="marker-title" type="text" autocomplete="off" /> |
| 69 | + </label> |
| 70 | + <label for="marker-description"> |
| 71 | + <span>Description</span> |
| 72 | + <textarea id="marker-description" name="marker-description" rows="3"></textarea> |
| 73 | + </label> |
| 74 | + <label for="marker-color" class="modal__color-picker"> |
| 75 | + <span>Marker color</span> |
| 76 | + <input id="marker-color" name="marker-color" type="color" value="#60A5FA" /> |
| 77 | + </label> |
| 78 | + <div class="modal__actions"> |
| 79 | + <button type="button" class="modal__button modal__button--secondary" data-action="cancel">Cancel</button> |
| 80 | + <button type="submit" class="modal__button modal__button--primary">Save marker</button> |
| 81 | + </div> |
| 82 | + </form> |
| 83 | + </div> |
61 | 84 | <script> |
62 | 85 | const { A } = window; |
63 | 86 | if (!A) { |
|
69 | 92 | const fileInput = document.getElementById('fits-file'); |
70 | 93 | const urlInput = document.getElementById('fits-url'); |
71 | 94 | const loadUrlButton = document.getElementById('load-url'); |
| 95 | + const addMarkerButton = document.getElementById('add-marker'); |
72 | 96 | const sampleButtons = Array.from(document.querySelectorAll('[data-fits-url]')); |
| 97 | + const markerModal = document.getElementById('marker-modal'); |
| 98 | + const markerForm = markerModal ? markerModal.querySelector('form') : null; |
| 99 | + const markerTitleInput = markerModal ? markerModal.querySelector('#marker-title') : null; |
| 100 | + const markerDescriptionInput = markerModal ? markerModal.querySelector('#marker-description') : null; |
| 101 | + const markerColorInput = markerModal ? markerModal.querySelector('#marker-color') : null; |
| 102 | + const markerCancelButton = markerModal ? markerModal.querySelector('[data-action="cancel"]') : null; |
73 | 103 |
|
74 | 104 | let aladinInstance = null; |
75 | 105 | let currentRequestId = 0; |
| 106 | + const markerLayersByColor = new Map(); |
| 107 | + let isAddingMarker = false; |
| 108 | + let storedStatusText = statusMessage.textContent; |
| 109 | + let resolveMarkerDialog = null; |
| 110 | + const MARKER_DEFAULT_COLOR = '#60A5FA'; |
| 111 | + let lastSelectedMarkerColor = MARKER_DEFAULT_COLOR; |
76 | 112 |
|
77 | 113 | const DEFAULT_SAMPLE = sampleButtons.length |
78 | 114 | ? { |
|
102 | 138 | parts.push(`FoV ≈ ${span.toFixed(2)}°`); |
103 | 139 | } |
104 | 140 | statusMessage.textContent = parts.join(' · ') || 'Ready to load a FITS image.'; |
| 141 | + storedStatusText = statusMessage.textContent; |
105 | 142 | } |
106 | 143 |
|
107 | 144 | function focusOnImage(ra, dec, fov) { |
|
140 | 177 | return 'Custom FITS'; |
141 | 178 | } |
142 | 179 |
|
| 180 | + function normaliseHexColor(input) { |
| 181 | + if (typeof input !== 'string') { |
| 182 | + return null; |
| 183 | + } |
| 184 | + const match = input.trim().match(/^#([0-9a-f]{6})$/i); |
| 185 | + return match ? `#${match[1].toUpperCase()}` : null; |
| 186 | + } |
| 187 | + |
| 188 | + function ensureMarkerLayer(color) { |
| 189 | + if (!aladinInstance) { |
| 190 | + return null; |
| 191 | + } |
| 192 | + const normalizedColor = normaliseHexColor(color) || MARKER_DEFAULT_COLOR; |
| 193 | + const existing = markerLayersByColor.get(normalizedColor); |
| 194 | + if (existing) { |
| 195 | + return existing; |
| 196 | + } |
| 197 | + const catalog = A.catalog({ |
| 198 | + name: `Markers ${normalizedColor}`, |
| 199 | + shape: 'marker', |
| 200 | + color: normalizedColor |
| 201 | + }); |
| 202 | + markerLayersByColor.set(normalizedColor, catalog); |
| 203 | + aladinInstance.addCatalog(catalog); |
| 204 | + return catalog; |
| 205 | + } |
| 206 | + |
| 207 | + function isMarkerDialogOpen() { |
| 208 | + return Boolean(markerModal && !markerModal.classList.contains('hidden')); |
| 209 | + } |
| 210 | + |
| 211 | + function closeMarkerDialog(result) { |
| 212 | + if (!markerModal || !markerForm) { |
| 213 | + if (typeof resolveMarkerDialog === 'function') { |
| 214 | + resolveMarkerDialog(result); |
| 215 | + resolveMarkerDialog = null; |
| 216 | + } |
| 217 | + return; |
| 218 | + } |
| 219 | + markerModal.classList.add('hidden'); |
| 220 | + markerModal.setAttribute('aria-hidden', 'true'); |
| 221 | + markerForm.reset(); |
| 222 | + if (markerColorInput) { |
| 223 | + markerColorInput.value = lastSelectedMarkerColor || MARKER_DEFAULT_COLOR; |
| 224 | + } |
| 225 | + if (typeof resolveMarkerDialog === 'function') { |
| 226 | + resolveMarkerDialog(result); |
| 227 | + resolveMarkerDialog = null; |
| 228 | + } |
| 229 | + } |
| 230 | + |
| 231 | + function openMarkerDialog(defaultTitle, defaultDescription, defaultColor) { |
| 232 | + const resolvedDefaultColor = normaliseHexColor(defaultColor) || MARKER_DEFAULT_COLOR; |
| 233 | + if (!markerModal || !markerForm || !markerTitleInput || !markerDescriptionInput || !markerColorInput) { |
| 234 | + if (typeof resolveMarkerDialog === 'function') { |
| 235 | + resolveMarkerDialog(null); |
| 236 | + resolveMarkerDialog = null; |
| 237 | + } |
| 238 | + return Promise.resolve({ |
| 239 | + title: defaultTitle, |
| 240 | + description: defaultDescription, |
| 241 | + color: resolvedDefaultColor |
| 242 | + }); |
| 243 | + } |
| 244 | + if (typeof resolveMarkerDialog === 'function') { |
| 245 | + resolveMarkerDialog(null); |
| 246 | + resolveMarkerDialog = null; |
| 247 | + } |
| 248 | + markerTitleInput.value = defaultTitle; |
| 249 | + markerDescriptionInput.value = defaultDescription; |
| 250 | + markerColorInput.value = resolvedDefaultColor; |
| 251 | + markerModal.classList.remove('hidden'); |
| 252 | + markerModal.setAttribute('aria-hidden', 'false'); |
| 253 | + return new Promise((resolve) => { |
| 254 | + resolveMarkerDialog = resolve; |
| 255 | + window.requestAnimationFrame(() => { |
| 256 | + markerTitleInput.focus(); |
| 257 | + markerTitleInput.select(); |
| 258 | + }); |
| 259 | + }); |
| 260 | + } |
| 261 | + |
143 | 262 | function loadFits(source, options = {}) { |
144 | 263 | if (!aladinInstance || !source) { |
145 | 264 | return; |
|
241 | 360 | }); |
242 | 361 | } |
243 | 362 |
|
| 363 | + if (markerForm && markerTitleInput && markerDescriptionInput && markerColorInput) { |
| 364 | + markerForm.addEventListener('submit', (event) => { |
| 365 | + event.preventDefault(); |
| 366 | + const chosenColor = normaliseHexColor(markerColorInput.value) || MARKER_DEFAULT_COLOR; |
| 367 | + closeMarkerDialog({ |
| 368 | + title: markerTitleInput.value, |
| 369 | + description: markerDescriptionInput.value, |
| 370 | + color: chosenColor |
| 371 | + }); |
| 372 | + }); |
| 373 | + } |
| 374 | + |
| 375 | + if (markerCancelButton) { |
| 376 | + markerCancelButton.addEventListener('click', () => { |
| 377 | + closeMarkerDialog(null); |
| 378 | + }); |
| 379 | + } |
| 380 | + |
| 381 | + if (markerModal) { |
| 382 | + markerModal.addEventListener('click', (event) => { |
| 383 | + if (event.target === markerModal) { |
| 384 | + closeMarkerDialog(null); |
| 385 | + } |
| 386 | + }); |
| 387 | + } |
| 388 | + |
| 389 | + window.addEventListener('keydown', (event) => { |
| 390 | + if (event.key === 'Escape' && isMarkerDialogOpen()) { |
| 391 | + event.preventDefault(); |
| 392 | + closeMarkerDialog(null); |
| 393 | + } |
| 394 | + }); |
| 395 | + |
| 396 | + function toggleMarkerMode(active) { |
| 397 | + if (!aladinInstance) { |
| 398 | + showError('The viewer is not ready yet. Please wait a moment and try again.'); |
| 399 | + return; |
| 400 | + } |
| 401 | + isAddingMarker = active; |
| 402 | + addMarkerButton.classList.toggle('marker-control--active', isAddingMarker); |
| 403 | + if (isAddingMarker) { |
| 404 | + storedStatusText = statusMessage.textContent; |
| 405 | + statusMessage.textContent = 'Click anywhere on the map to place a marker. Press Esc to cancel.'; |
| 406 | + } else { |
| 407 | + statusMessage.textContent = storedStatusText; |
| 408 | + } |
| 409 | + if (!isAddingMarker) { |
| 410 | + window.removeEventListener('keydown', cancelMarkerModeOnEscape); |
| 411 | + } else { |
| 412 | + window.addEventListener('keydown', cancelMarkerModeOnEscape); |
| 413 | + } |
| 414 | + } |
| 415 | + |
| 416 | + function cancelMarkerModeOnEscape(event) { |
| 417 | + if (event.key === 'Escape') { |
| 418 | + if (isMarkerDialogOpen()) { |
| 419 | + return; |
| 420 | + } |
| 421 | + toggleMarkerMode(false); |
| 422 | + } |
| 423 | + } |
| 424 | + |
| 425 | + async function createMarkerAt(ra, dec) { |
| 426 | + const defaultTitle = `Marker @ RA ${ra.toFixed(3)}, Dec ${dec.toFixed(3)}`; |
| 427 | + const defaultDescription = `Coordinates: RA ${ra.toFixed(3)}, Dec ${dec.toFixed(3)}`; |
| 428 | + const dialogResult = await openMarkerDialog(defaultTitle, defaultDescription, lastSelectedMarkerColor); |
| 429 | + if (!dialogResult) { |
| 430 | + return false; |
| 431 | + } |
| 432 | + const trimmedTitle = (dialogResult.title || '').trim(); |
| 433 | + const trimmedDescription = (dialogResult.description || '').trim(); |
| 434 | + const validColor = normaliseHexColor(dialogResult.color) || MARKER_DEFAULT_COLOR; |
| 435 | + lastSelectedMarkerColor = validColor; |
| 436 | + const targetLayer = ensureMarkerLayer(validColor); |
| 437 | + if (!targetLayer) { |
| 438 | + showError('Unable to access the marker layer. Please try again in a moment.'); |
| 439 | + return false; |
| 440 | + } |
| 441 | + const markerOptions = { |
| 442 | + popupTitle: trimmedTitle || defaultTitle, |
| 443 | + popupDesc: trimmedDescription || defaultDescription, |
| 444 | + color: validColor |
| 445 | + }; |
| 446 | + const marker = A.marker(ra, dec, markerOptions); |
| 447 | + targetLayer.addSources([marker]); |
| 448 | + aladinInstance.gotoRaDec(ra, dec); |
| 449 | + const confirmation = `${markerOptions.popupTitle} saved at RA ${ra.toFixed(3)}, Dec ${dec.toFixed(3)}`; |
| 450 | + statusMessage.textContent = confirmation; |
| 451 | + storedStatusText = confirmation; |
| 452 | + return true; |
| 453 | + } |
| 454 | + |
244 | 455 | async function initialiseViewer() { |
245 | 456 | if (!A) { |
246 | 457 | showError('Aladin Lite could not be loaded. Please check your connection and refresh the page.'); |
|
257 | 468 | target: 'M 31' |
258 | 469 | }); |
259 | 470 |
|
260 | | - var marker1 = A.marker(0, 0, {popupTitle: "Test", popupDesc: "TEST"}); |
261 | | - var markerLayer = A.catalog(); |
262 | | - aladinInstance.addCatalog(markerLayer); |
263 | | - markerLayer.addSources([marker1]); |
| 471 | + ensureMarkerLayer(MARKER_DEFAULT_COLOR); |
| 472 | + aladinInstance.on('click', async (point) => { |
| 473 | + if (!isAddingMarker || !point) { |
| 474 | + return; |
| 475 | + } |
| 476 | + const { ra, dec } = point; |
| 477 | + if (Number.isFinite(ra) && Number.isFinite(dec)) { |
| 478 | + await createMarkerAt(ra, dec); |
| 479 | + } |
| 480 | + toggleMarkerMode(false); |
| 481 | + }); |
264 | 482 | }); |
265 | 483 |
|
266 | 484 | initialiseSampleButtons(); |
|
287 | 505 | handleUrlLoad(); |
288 | 506 | } |
289 | 507 | }); |
| 508 | + addMarkerButton.addEventListener('click', () => { |
| 509 | + toggleMarkerMode(!isAddingMarker); |
| 510 | + }); |
290 | 511 |
|
291 | 512 | initialiseViewer(); |
292 | 513 | </script> |
|
0 commit comments