├── .gitattributes ├── .gitignore ├── AM-favicon.png ├── AstroMosaic.html ├── AstroMosaicEngine.js ├── AstroMosaicEngine.py ├── AstroMosaicEngineExample.html ├── AstroMosaicEngineExample.py ├── README.md ├── information-outline.png ├── menu-24px.svg ├── tutorial-system.css └── tutorial-system.js /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bat 2 | *.pyc 3 | .vscode 4 | .tmp.driveupload 5 | -------------------------------------------------------------------------------- /AM-favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarmoruuth/AstroMosaic/726029baec2c9c44a381f4425cb3e0837a3146b4/AM-favicon.png -------------------------------------------------------------------------------- /AstroMosaicEngine.js: -------------------------------------------------------------------------------- 1 | /*************************************************************************\ 2 | * 3 | * AstroMosaic telescope planner engine. (C) Jarmo Ruuth, 2018-2025 4 | * 5 | * An embeddable JavaScript file of AstroMosaic engine. 6 | * It can show: 7 | * - Target with telescope field of view using Aladin Lite 8 | * - Target day visibility graphs 9 | * - Target year visibility graphs 10 | * 11 | \*************************************************************************/ 12 | 13 | /************************************************************************* 14 | * 15 | * AstroMosaicEngine 16 | * 17 | * AstroMosaic entry point for embedded Javascript. 18 | * 19 | * Notes: 20 | * - If calling AstroMosaicEngine multiple times Aladin view may show 21 | * multiple views. This can be solved by setting AstroMosaicEngine 22 | * return object to null before calling it again. 23 | * 24 | * Parameters: 25 | * 26 | * target 27 | * Image target as a name, coordinates or a comma 28 | * Separated list of coordinates. 29 | * 30 | * params 31 | * Parameters in JSON format for showing the requested view 32 | * or views. 33 | * { 34 | * fov_x : x fov in degrees, 35 | * fov_y : y fov in degrees, 36 | * grid_type : grid type, "fov" or "mosaic", if not set, "fov" is used, 37 | * grid_size_x : number of grid panels in x direction, if not set, 1 is used 38 | * grid_size_y : number of grid panels y direction, if not set, 1 is used 39 | * grid_overlap : grid overlap in percentage, if not set, 20 is used 40 | * location_lat : location latitude, 41 | * location_lng : location longitude, 42 | * horizonSoft : soft horizon limit or null, 43 | * horizonHard : hard horizon limit or null, 44 | * meridian_transit : meridian transit or null, 45 | * UTCdate_ms : start of day UTC date in milliseconds 46 | * or null for current day, 47 | * timezoneOffset : difference between UTC time and local time, in hours, 48 | * null for UTC, should match with lat/lng 49 | * isCustomMode : true to use custom colors, false otherwise 50 | * if true, all custom colors below must be given, 51 | * chartTextColor : custom chart text color, 52 | * gridlinesColor : custom chart grid lines color, 53 | * backgroundColor : custom chart background color 54 | * } 55 | * 56 | * target_div 57 | * Div section name for showing the target view, or null. 58 | * 59 | * day_div 60 | * Div section name for showing the day visibility view, or null. 61 | * 62 | * year_div 63 | * Div section name for showing the year visibility view, or null. 64 | * 65 | * radec_div 66 | * Div section name for showing target coordinates, or mosaic panel coordinates. 67 | * 68 | * Requirements: 69 | * 70 | * Aladin Lite needs the following CSS to be loaded:: 71 | * 72 | * 73 | * Aladin Lite needs the following scripts to be loaded: 74 | * 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 |
18 | 19 | 20 | 21 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /AstroMosaicEngineExample.py: -------------------------------------------------------------------------------- 1 | from AstroMosaicEngine import AstroMosaicEngine 2 | 3 | # Convert arcminutes to degrees 4 | def arcminutes_to_degrees(arcminutes): 5 | """Convert arcminutes to degrees.""" 6 | return arcminutes / 60.0 7 | 8 | # --- Example Usage of AstroMosaicEngine --- 9 | if __name__ == '__main__': 10 | # Observer's location (Telescope Live SPA-1) 11 | LATITUDE = 37.4988 12 | LONGITUDE = -2.42178 13 | TIMEZONE = 0 # UTC 14 | 15 | # Telescope/Camera setup 16 | # Needed only for mosaic calculations 17 | FOV_X = arcminutes_to_degrees(321) # degrees 18 | FOV_Y = arcminutes_to_degrees(214) # degrees 19 | 20 | # Target and Date 21 | # TARGET_NAME = "M81" Name resolver does not work 22 | # TARGET_NAME = "148.88821 69.06528" 23 | # TARGET_NAME = "9.92588 69.06528" 24 | TARGET_NAME = "09:55:33.17 69:03:55.00" 25 | OBSERVATION_DATE = "2025-06-09" # A specific date 26 | 27 | print(f"Initializing AstroMosaic Engine for target: {TARGET_NAME}") 28 | print(f"Calculating for the night of: {OBSERVATION_DATE}\n") 29 | try: 30 | engine = AstroMosaicEngine( 31 | target=TARGET_NAME, 32 | lat=LATITUDE, 33 | lon=LONGITUDE, 34 | fov_x_deg=FOV_X, 35 | fov_y_deg=FOV_Y, 36 | observation_date=OBSERVATION_DATE, # Pass the specific date here 37 | timezone_offset=TIMEZONE 38 | ) 39 | 40 | # 1. Get Day Visibility Table 41 | print("--- Daily Visibility Table ---") 42 | day_data = engine.get_day_visibility() 43 | if day_data: 44 | # Print header 45 | print(f"{'Timestamp (UTC)':<22} | {'Target Alt (deg)':<20} | {'Target Az (deg)':<20} | {'Moon Alt (deg)':<20}") 46 | print("-" * 88) 47 | # Print first 10 and last 5 rows for brevity 48 | for row in day_data[:10]: 49 | print( 50 | f"{row['timestamp_utc']:<22} | {row['target_altitude_deg']:<20.2f} | " 51 | f"{row['target_azimuth_deg']:<20.2f} | {row['moon_altitude_deg']:<20.2f}" 52 | ) 53 | if len(day_data) > 15: 54 | print("...") 55 | for row in day_data[-5:]: 56 | print( 57 | f"{row['timestamp_utc']:<22} | {row['target_altitude_deg']:<20.2f} | " 58 | f"{row['target_azimuth_deg']:<20.2f} | {row['moon_altitude_deg']:<20.2f}" 59 | ) 60 | else: 61 | print("Could not generate daily visibility data.") 62 | 63 | print("\n" + "="*88 + "\n") 64 | 65 | # 2. Get Year Visibility Table 66 | print("--- Yearly Visibility Table (at midnight) ---") 67 | year_data = engine.get_year_visibility() 68 | if year_data: 69 | print(f"{'Date':<12} | {'Target Alt (deg)':<20} | {'Moon Alt (deg)':<20} | {'Moon Phase (%)':<20}") 70 | print("-" * 80) 71 | # Print data for every ~30 days 72 | for i in range(0, 365, 30): 73 | row = year_data[i] 74 | print( 75 | f"{row['date']:<12} | {row['target_altitude_at_midnight_deg']:<20.2f} | " 76 | f"{row['moon_altitude_at_midnight_deg']:<20.2f} | {row['moon_phase_percent']:<20.1f}" 77 | ) 78 | else: 79 | print("Could not generate yearly visibility data.") 80 | 81 | print("\n" + "="*88 + "\n") 82 | 83 | # 3. Get Mosaic Coordinates Table 84 | print("--- 3x3 Mosaic Coordinates Table ---") 85 | mosaic_data = engine.get_mosaic_coordinates(grid_size_x=3, grid_size_y=3) 86 | if mosaic_data: 87 | print(f"{'Panel':<8} | {'RA (deg)':<18} | {'Dec (deg)':<18}") 88 | print("-" * 48) 89 | for row in mosaic_data: 90 | for panel in row: 91 | print(f"{panel['panel']:<8} | {panel['ra_str']:<18} | {panel['dec_str']:<18}") 92 | else: 93 | print("Could not generate mosaic data.") 94 | 95 | except (ValueError, TypeError) as e: 96 | print(f"Error: {e}") -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Astro Mosaic Telescope Planner 2 | 3 | Astro Mosaic is a tool for planning telescope observations. It shows a visual view of the target using a selected telescope, 4 | visibility during night and it can calculate mosaic coordinates. There are predefined telescope settings for 5 | some remote telescope services. 6 | 7 | AstroMosaic tool includes 8 | - Visual view of target using Aladin Sky Atlas 9 | - Target name resolution using Sesame interface 10 | - Field of View (FoV) view of target with a chosen telescope 11 | - View and calculate mosaic coordinates up to 10x10 size 12 | - Target visibility during the night 13 | - Moon altitude and distance from the target 14 | - Target and moon altitude over next 12 months 15 | - Catalog lists for selecting the target 16 | - Filtering of catalog list based on altitude, time and distance from the moon 17 | - Multiple target coordinate formats are supported: 18 | HH:MM:SS DD:MM:SS, HH MM SS DD MM SS, 19 | HH:MM:SS/DD:MM:SS, HH MM SS/DD MM SS, 20 | HHMMSS DDMMSS, HH.dec DD.dec 21 | - A comma separated list can be given to show multiple targets 22 | - Wiki interface to show target information 23 | 24 | More details can be found at https://ruuth.xyz/AstroMosaicInfo.html. 25 | 26 | AstroMosaic can also be embedded in a page by calling Javascript function or inside an Iframe. 27 | More details can be found at https://ruuth.xyz/AstroMosaicConfigurationInfo.html. 28 | -------------------------------------------------------------------------------- /information-outline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jarmoruuth/AstroMosaic/726029baec2c9c44a381f4425cb3e0837a3146b4/information-outline.png -------------------------------------------------------------------------------- /menu-24px.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tutorial-system.css: -------------------------------------------------------------------------------- 1 | /* Interactive Tutorial System CSS */ 2 | 3 | .tutorial-overlay { 4 | position: fixed; 5 | top: 0; 6 | left: 0; 7 | width: 100%; 8 | height: 100%; 9 | background: rgba(0, 0, 0, 0.4); 10 | z-index: 9998; 11 | pointer-events: none; 12 | transition: opacity 0.3s ease; 13 | } 14 | 15 | .tutorial-overlay.hidden { 16 | opacity: 0; 17 | pointer-events: none; 18 | } 19 | 20 | .tutorial-spotlight { 21 | position: absolute; 22 | box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.4); 23 | border-radius: 4px; 24 | transition: all 0.3s ease; 25 | z-index: 9999; 26 | pointer-events: none; 27 | } 28 | 29 | .tutorial-tooltip { 30 | position: fixed; 31 | background: white; 32 | border-radius: 8px; 33 | padding: 20px; 34 | max-width: 350px; 35 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); 36 | z-index: 10000; 37 | pointer-events: auto; 38 | max-height: calc(100vh - 40px); 39 | display: flex; 40 | flex-direction: column; 41 | } 42 | 43 | .tutorial-tooltip-header { 44 | display: flex; 45 | justify-content: space-between; 46 | align-items: center; 47 | margin-bottom: 12px; 48 | } 49 | 50 | .tutorial-tooltip-title { 51 | font-size: 18px; 52 | font-weight: bold; 53 | color: #333; 54 | margin: 0; 55 | } 56 | 57 | .tutorial-tooltip-close { 58 | background: none; 59 | border: none; 60 | font-size: 24px; 61 | color: #999; 62 | cursor: pointer; 63 | padding: 0; 64 | width: 30px; 65 | height: 30px; 66 | display: flex; 67 | align-items: center; 68 | justify-content: center; 69 | border-radius: 4px; 70 | transition: background 0.2s; 71 | } 72 | 73 | .tutorial-tooltip-close:hover { 74 | background: #f0f0f0; 75 | color: #666; 76 | } 77 | 78 | .tutorial-tooltip-content { 79 | color: #555; 80 | line-height: 1.6; 81 | margin-bottom: 16px; 82 | flex: 1; 83 | overflow-y: auto; 84 | min-height: 0; 85 | scrollbar-width: thin; 86 | scrollbar-color: #ccc #f0f0f0; 87 | padding-right: 5px; 88 | } 89 | 90 | .tutorial-tooltip-content::-webkit-scrollbar { 91 | width: 8px; 92 | } 93 | 94 | .tutorial-tooltip-content::-webkit-scrollbar-track { 95 | background: #f0f0f0; 96 | border-radius: 4px; 97 | } 98 | 99 | .tutorial-tooltip-content::-webkit-scrollbar-thumb { 100 | background: #ccc; 101 | border-radius: 4px; 102 | } 103 | 104 | .tutorial-tooltip-content::-webkit-scrollbar-thumb:hover { 105 | background: #999; 106 | } 107 | 108 | .tutorial-tooltip-footer { 109 | display: flex; 110 | justify-content: space-between; 111 | align-items: center; 112 | } 113 | 114 | .tutorial-tooltip-progress { 115 | color: #888; 116 | font-size: 14px; 117 | } 118 | 119 | .tutorial-tooltip-buttons { 120 | display: flex; 121 | gap: 8px; 122 | } 123 | 124 | .tutorial-button { 125 | padding: 8px 16px; 126 | border: none; 127 | border-radius: 4px; 128 | cursor: pointer; 129 | font-size: 14px; 130 | font-weight: 500; 131 | transition: all 0.2s; 132 | } 133 | 134 | .tutorial-button-prev { 135 | background: #e0e0e0; 136 | color: #333; 137 | } 138 | 139 | .tutorial-button-prev:hover:not(:disabled) { 140 | background: #d0d0d0; 141 | } 142 | 143 | .tutorial-button-next { 144 | background: #4CAF50; 145 | color: white; 146 | } 147 | 148 | .tutorial-button-next:hover:not(:disabled) { 149 | background: #45a049; 150 | } 151 | 152 | .tutorial-button:disabled { 153 | opacity: 0.5; 154 | cursor: not-allowed; 155 | } 156 | 157 | .tutorial-start-button { 158 | position: fixed; 159 | bottom: 20px; 160 | right: 20px; 161 | padding: 12px 24px; 162 | background: #2196F3; 163 | color: white; 164 | border: none; 165 | border-radius: 24px; 166 | cursor: pointer; 167 | font-size: 16px; 168 | font-weight: 500; 169 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); 170 | z-index: 1000; 171 | transition: all 0.3s; 172 | } 173 | 174 | .tutorial-start-button:hover { 175 | background: #1976D2; 176 | box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3); 177 | } 178 | 179 | .tutorial-start-button.hidden { 180 | display: none; 181 | } 182 | 183 | /* Arrow pointing to highlighted element */ 184 | .tutorial-arrow { 185 | position: absolute; 186 | width: 0; 187 | height: 0; 188 | z-index: 9999; 189 | } 190 | 191 | .tutorial-arrow.arrow-top { 192 | border-left: 10px solid transparent; 193 | border-right: 10px solid transparent; 194 | border-bottom: 10px solid white; 195 | } 196 | 197 | .tutorial-arrow.arrow-bottom { 198 | border-left: 10px solid transparent; 199 | border-right: 10px solid transparent; 200 | border-top: 10px solid white; 201 | } 202 | 203 | .tutorial-arrow.arrow-left { 204 | border-top: 10px solid transparent; 205 | border-bottom: 10px solid transparent; 206 | border-right: 10px solid white; 207 | } 208 | 209 | .tutorial-arrow.arrow-right { 210 | border-top: 10px solid transparent; 211 | border-bottom: 10px solid transparent; 212 | border-left: 10px solid white; 213 | } 214 | -------------------------------------------------------------------------------- /tutorial-system.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Interactive Tutorial System 3 | * Highlights page elements and provides step-by-step guidance 4 | */ 5 | 6 | class TutorialSystem { 7 | constructor(steps) { 8 | this.steps = steps; 9 | this.currentStep = 0; 10 | this.isActive = false; 11 | this.elements = {}; 12 | 13 | this.init(); 14 | } 15 | 16 | init() { 17 | // Create tutorial elements 18 | this.createElements(); 19 | 20 | // Add event listeners 21 | this.addEventListeners(); 22 | } 23 | 24 | createElements() { 25 | // Create overlay 26 | this.elements.overlay = document.createElement('div'); 27 | this.elements.overlay.className = 'tutorial-overlay hidden'; 28 | document.body.appendChild(this.elements.overlay); 29 | 30 | // Create spotlight 31 | this.elements.spotlight = document.createElement('div'); 32 | this.elements.spotlight.className = 'tutorial-spotlight'; 33 | this.elements.spotlight.style.display = 'none'; 34 | document.body.appendChild(this.elements.spotlight); 35 | 36 | // Create tooltip 37 | this.elements.tooltip = document.createElement('div'); 38 | this.elements.tooltip.className = 'tutorial-tooltip'; 39 | this.elements.tooltip.style.display = 'none'; 40 | this.elements.tooltip.innerHTML = ` 41 |
42 |

43 | 44 |
45 |
46 | 53 | `; 54 | document.body.appendChild(this.elements.tooltip); 55 | 56 | // Create arrow 57 | this.elements.arrow = document.createElement('div'); 58 | this.elements.arrow.className = 'tutorial-arrow'; 59 | this.elements.arrow.style.display = 'none'; 60 | document.body.appendChild(this.elements.arrow); 61 | 62 | // Create start button 63 | this.elements.startButton = document.createElement('button'); 64 | this.elements.startButton.className = 'tutorial-start-button'; 65 | this.elements.startButton.innerHTML = '🎓 Start Tutorial'; 66 | document.body.appendChild(this.elements.startButton); 67 | } 68 | 69 | addEventListeners() { 70 | // Start button 71 | this.elements.startButton.addEventListener('click', () => this.start()); 72 | 73 | // Close button 74 | this.elements.tooltip.querySelector('.tutorial-tooltip-close').addEventListener('click', () => this.end()); 75 | 76 | // Previous button 77 | this.elements.tooltip.querySelector('.tutorial-button-prev').addEventListener('click', () => this.previous()); 78 | 79 | // Next button 80 | this.elements.tooltip.querySelector('.tutorial-button-next').addEventListener('click', () => this.next()); 81 | 82 | // Allow clicking on highlighted element 83 | this.elements.spotlight.addEventListener('click', (e) => { 84 | if (this.steps[this.currentStep].allowInteraction) { 85 | // Let the click through to the actual element 86 | } 87 | }); 88 | 89 | // Escape key to close 90 | document.addEventListener('keydown', (e) => { 91 | if (e.key === 'Escape' && this.isActive) { 92 | this.end(); 93 | } 94 | }); 95 | 96 | // Resize handling 97 | window.addEventListener('resize', () => { 98 | if (this.isActive) { 99 | this.showStep(this.currentStep); 100 | } 101 | }); 102 | } 103 | 104 | start() { 105 | this.isActive = true; 106 | this.currentStep = 0; 107 | this.elements.startButton.classList.add('hidden'); 108 | this.elements.overlay.classList.remove('hidden'); 109 | this.showStep(0); 110 | } 111 | 112 | end() { 113 | this.isActive = false; 114 | this.elements.overlay.classList.add('hidden'); 115 | this.elements.spotlight.style.display = 'none'; 116 | this.elements.tooltip.style.display = 'none'; 117 | this.elements.arrow.style.display = 'none'; 118 | this.elements.startButton.classList.remove('hidden'); 119 | } 120 | 121 | next() { 122 | if (this.currentStep < this.steps.length - 1) { 123 | this.currentStep++; 124 | this.showStep(this.currentStep); 125 | } else { 126 | this.end(); 127 | } 128 | } 129 | 130 | previous() { 131 | if (this.currentStep > 0) { 132 | this.currentStep--; 133 | this.showStep(this.currentStep); 134 | } 135 | } 136 | 137 | showStep(stepIndex) { 138 | const step = this.steps[stepIndex]; 139 | 140 | // Get target element 141 | const targetElement = document.querySelector(step.element); 142 | 143 | if (!targetElement) { 144 | console.warn(`Tutorial: Element not found: ${step.element}`); 145 | return; 146 | } 147 | 148 | // Scroll element into view 149 | targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); 150 | 151 | // Position spotlight 152 | setTimeout(() => { 153 | const rect = targetElement.getBoundingClientRect(); 154 | const padding = step.padding || 8; 155 | 156 | this.elements.spotlight.style.display = 'block'; 157 | this.elements.spotlight.style.left = (rect.left - padding) + 'px'; 158 | this.elements.spotlight.style.top = (rect.top - padding) + 'px'; 159 | this.elements.spotlight.style.width = (rect.width + padding * 2) + 'px'; 160 | this.elements.spotlight.style.height = (rect.height + padding * 2) + 'px'; 161 | 162 | // Position tooltip 163 | this.positionTooltip(rect, step); 164 | 165 | // Update tooltip content 166 | this.updateTooltipContent(step, stepIndex); 167 | 168 | this.elements.tooltip.style.display = 'block'; 169 | }, 300); 170 | } 171 | 172 | positionTooltip(targetRect, step) { 173 | const tooltip = this.elements.tooltip; 174 | const arrow = this.elements.arrow; 175 | 176 | let position = step.position || 'auto'; 177 | 178 | // First, position the tooltip to get accurate dimensions 179 | tooltip.style.left = '0px'; 180 | tooltip.style.top = '0px'; 181 | tooltip.style.maxHeight = 'none'; 182 | 183 | // Force a reflow to get accurate measurements 184 | tooltip.offsetHeight; 185 | const tooltipRect = tooltip.getBoundingClientRect(); 186 | 187 | // Check if tooltip is taller than viewport 188 | const viewportHeight = window.innerHeight; 189 | const maxTooltipHeight = viewportHeight - 40; // 20px margin top and bottom 190 | 191 | if (tooltipRect.height > maxTooltipHeight) { 192 | tooltip.style.maxHeight = maxTooltipHeight + 'px'; 193 | tooltip.style.overflowY = 'auto'; 194 | } 195 | 196 | // Get updated dimensions after potential height adjustment 197 | const finalTooltipRect = tooltip.getBoundingClientRect(); 198 | 199 | // Auto-position if needed 200 | if (position === 'auto') { 201 | const spaceAbove = targetRect.top; 202 | const spaceBelow = window.innerHeight - targetRect.bottom; 203 | const spaceLeft = targetRect.left; 204 | const spaceRight = window.innerWidth - targetRect.right; 205 | 206 | const spaces = { 207 | top: spaceAbove, 208 | bottom: spaceBelow, 209 | left: spaceLeft, 210 | right: spaceRight 211 | }; 212 | 213 | position = Object.keys(spaces).reduce((a, b) => spaces[a] > spaces[b] ? a : b); 214 | } 215 | 216 | let tooltipX, tooltipY, arrowX, arrowY; 217 | arrow.className = 'tutorial-arrow'; 218 | 219 | switch (position) { 220 | case 'top': 221 | tooltipX = targetRect.left + (targetRect.width / 2) - (finalTooltipRect.width / 2); 222 | tooltipY = targetRect.top - finalTooltipRect.height - 20; 223 | arrowX = targetRect.left + (targetRect.width / 2) - 10; 224 | arrowY = targetRect.top - 10; 225 | arrow.classList.add('arrow-top'); 226 | break; 227 | 228 | case 'bottom': 229 | tooltipX = targetRect.left + (targetRect.width / 2) - (finalTooltipRect.width / 2); 230 | tooltipY = targetRect.bottom + 20; 231 | arrowX = targetRect.left + (targetRect.width / 2) - 10; 232 | arrowY = targetRect.bottom; 233 | arrow.classList.add('arrow-bottom'); 234 | break; 235 | 236 | case 'left': 237 | tooltipX = targetRect.left - finalTooltipRect.width - 20; 238 | tooltipY = targetRect.top + (targetRect.height / 2) - (finalTooltipRect.height / 2); 239 | arrowX = targetRect.left - 10; 240 | arrowY = targetRect.top + (targetRect.height / 2) - 10; 241 | arrow.classList.add('arrow-left'); 242 | break; 243 | 244 | case 'right': 245 | tooltipX = targetRect.right + 20; 246 | tooltipY = targetRect.top + (targetRect.height / 2) - (finalTooltipRect.height / 2); 247 | arrowX = targetRect.right; 248 | arrowY = targetRect.top + (targetRect.height / 2) - 10; 249 | arrow.classList.add('arrow-right'); 250 | break; 251 | } 252 | 253 | // Keep tooltip on screen horizontally 254 | tooltipX = Math.max(10, Math.min(tooltipX, window.innerWidth - finalTooltipRect.width - 10)); 255 | 256 | // Keep tooltip on screen vertically with better handling 257 | const minY = 10; 258 | const maxY = window.innerHeight - finalTooltipRect.height - 10; 259 | 260 | // If tooltip would go off bottom, move it up 261 | if (tooltipY + finalTooltipRect.height > window.innerHeight - 10) { 262 | tooltipY = window.innerHeight - finalTooltipRect.height - 10; 263 | } 264 | 265 | // If tooltip would go off top, move it down 266 | if (tooltipY < 10) { 267 | tooltipY = 10; 268 | } 269 | 270 | // Apply final position 271 | tooltip.style.left = tooltipX + 'px'; 272 | tooltip.style.top = tooltipY + 'px'; 273 | 274 | arrow.style.left = arrowX + 'px'; 275 | arrow.style.top = arrowY + 'px'; 276 | arrow.style.display = 'block'; 277 | } 278 | 279 | updateTooltipContent(step, stepIndex) { 280 | const tooltip = this.elements.tooltip; 281 | 282 | tooltip.querySelector('.tutorial-tooltip-title').textContent = step.title; 283 | 284 | // Support HTML content if specified, otherwise treat as plain text 285 | const contentElement = tooltip.querySelector('.tutorial-tooltip-content'); 286 | if (step.contentHTML) { 287 | contentElement.innerHTML = step.contentHTML; 288 | } else { 289 | // Convert \n to
for plain text content 290 | const formattedContent = step.content.replace(/\n/g, '
'); 291 | contentElement.innerHTML = formattedContent; 292 | } 293 | 294 | tooltip.querySelector('.tutorial-tooltip-progress').textContent = 295 | `Step ${stepIndex + 1} of ${this.steps.length}`; 296 | 297 | // Update button states 298 | const prevButton = tooltip.querySelector('.tutorial-button-prev'); 299 | const nextButton = tooltip.querySelector('.tutorial-button-next'); 300 | 301 | prevButton.disabled = stepIndex === 0; 302 | 303 | if (stepIndex === this.steps.length - 1) { 304 | nextButton.textContent = 'Finish'; 305 | } else { 306 | nextButton.textContent = 'Next'; 307 | } 308 | } 309 | } 310 | 311 | // Export for use 312 | window.TutorialSystem = TutorialSystem; 313 | --------------------------------------------------------------------------------