1// viewlineCalculator.js - Pure functions for viewline calculation 2 3import { createSolarCalculator } from './utils/astronomy.js'; 4import { sendDiscordError } from './utils/sendDiscordError.js'; 5 6const VIEWLINE_CONSTANTS = { 7 MIN_LATITUDE: 45, 8 LONGITUDE_BINS: 360, 9 MIN_FILLED_BINS: 10, 10 SMOOTHING_KERNEL: 2, 11 OFFSET_KM: 650 12}; 13 14/** 15 * Bin aurora points by longitude, keeping only the southernmost latitude per bin 16 */ 17export function binPointsByLongitude(points, threshold, reduceForDaylight, lastUpdateTime) { 18 const minLatByLonIndex = new Array(VIEWLINE_CONSTANTS.LONGITUDE_BINS).fill(null); 19 let validPointCount = 0; 20 // Counted rather than logged per point: this loop runs over ~65,000 points. 21 let solarFactorFailures = 0; 22 let firstSolarFactorError = null; 23 24 // The sun's ephemeris depends only on the time, not the point â build the 25 // per-time calculator once instead of rebuilding it inside the 65,000-point 26 // loop. Identical result to getSolarFactor(lat, lon, time). 27 const solarAt = reduceForDaylight ? createSolarCalculator(lastUpdateTime || new Date()) : null; 28 29 for (const p of points) { 30 const lat = Number(p.lat); 31 if (isNaN(lat) || lat < VIEWLINE_CONSTANTS.MIN_LATITUDE) continue; 32 33 let prob = Number(p.p) || 0; 34 if (reduceForDaylight) { 35 try { 36 prob *= solarAt(lat, p.lon); 37 } catch (e) { 38 // Falling back to the undimmed probability. Tallied so a broken 39 // solar calculation surfaces once instead of 65,000 times. 40 solarFactorFailures++; 41 if (!firstSolarFactorError) firstSolarFactorError = e; 42 } 43 } 44 45 if (prob < threshold) continue; 46 validPointCount++; 47 48 // Normalize longitude and convert to bin index 49 let lon = Number(p.lon); 50 while (lon <= -180) lon += 360; 51 while (lon > 180) lon -= 360; 52 53 const idx = Math.max(0, Math.min(359, Math.floor(lon + 180))); 54 55 // Keep only southernmost latitude per bin 56 if (minLatByLonIndex[idx] === null || lat < minLatByLonIndex[idx]) { 57 minLatByLonIndex[idx] = lat; 58 } 59 } 60 61 if (solarFactorFailures > 0) { 62 console.warn(`[Viewline] Daylight dimming failed for ${solarFactorFailures} point(s):`, 63 firstSolarFactorError?.message); 64 // Isolated failures are noise; a wholesale failure means the viewline is 65 // silently wrong, which is worth knowing about. 66 if (solarFactorFailures > points.length / 2) { 67 sendDiscordError('Viewline: solar factor failing', firstSolarFactorError, { 68 failures: solarFactorFailures, 69 totalPoints: points.length, 70 }); 71 } 72 } 73 74 const filledBins = minLatByLonIndex.filter(v => v !== null).length; 75 return { bins: minLatByLonIndex, filledBins, validPointCount }; 76} 77 78/** 79 * Fill gaps in binned data using nearest neighbor interpolation 80 */ 81export function interpolateGaps(bins) { 82 const filled = [...bins]; 83 84 for (let i = 0; i < VIEWLINE_CONSTANTS.LONGITUDE_BINS; i++) { 85 if (filled[i] !== null) continue; 86 87 // Find nearest left neighbor 88 let leftVal = null, leftDist = 0; 89 for (let d = 1; d < 180; d++) { 90 const idx = (i - d + VIEWLINE_CONSTANTS.LONGITUDE_BINS) % VIEWLINE_CONSTANTS.LONGITUDE_BINS; 91 if (filled[idx] !== null) { 92 leftVal = filled[idx]; 93 leftDist = d; 94 break; 95 } 96 } 97 98 // Find nearest right neighbor 99 let rightVal = null, rightDist = 0; 100 for (let d = 1; d < 180; d++) { 101 const idx = (i + d) % VIEWLINE_CONSTANTS.LONGITUDE_BINS; 102 if (filled[idx] !== null) { 103 rightVal = filled[idx]; 104 rightDist = d; 105 break; 106 } 107 } 108 109 // Interpolate between neighbors 110 if (leftVal !== null && rightVal !== null) { 111 const totalDist = leftDist + rightDist; 112 const fraction = leftDist / totalDist; 113 filled[i] = leftVal + fraction * (rightVal - leftVal); 114 } else if (leftVal !== null) { 115 filled[i] = leftVal; 116 } else if (rightVal !== null) { 117 filled[i] = rightVal; 118 } 119 } 120 121 return filled; 122} 123 124/** 125 * Apply moving average smoothing to latitude values 126 */ 127export function smoothLatitudes(lats) { 128 const smoothed = new Array(VIEWLINE_CONSTANTS.LONGITUDE_BINS);
129 const kernel = VIEWLINE_CONSTANTS.SMOOTHING_KERNEL; 130 131 for (let i = 0; i < VIEWLINE_CONSTANTS.LONGITUDE_BINS; i++) { 132 let sum = 0; 133 let count = 0; 134 135 for (let k = -kernel; k <= kernel; k++) { 136 const idx = (i + k + VIEWLINE_CONSTANTS.LONGITUDE_BINS) % VIEWLINE_CONSTANTS.LONGITUDE_BINS; 137 if (lats[idx] !== null) { 138 sum += lats[idx]; 139 count++; 140 } 141 } 142 143 smoothed[i] = count > 0 ? sum / count : lats[i]; 144 } 145 146 return smoothed; 147} 148 149/** 150 * The point `km` kilometres from (lon, lat) along `bearing` degrees, on a 151 * sphere. Operation for operation what turf.destination does (same Earth 152 * radius, same angle normalisation), so the viewline is unchanged: the site 153 * used to load all of turf from a CDN for this one call, and main.js could not 154 * start until it arrived. 155 * @returns {[number, number]} [lon, lat] 156 */ 157export function destination(lon, lat, km, bearing) { 158 const toRad = (deg) => (deg % 360) * Math.PI / 180; 159 const toDeg = (rad) => (rad % (2 * Math.PI)) * 180 / Math.PI; 160 const lon1 = toRad(lon); 161 const lat1 = toRad(lat); 162 const theta = toRad(bearing); 163 const delta = km / (6371008.8 / 1000); 164 const lat2 = Math.asin(Math.sin(lat1) * Math.cos(delta) + Math.cos(lat1) * Math.sin(delta) * Math.cos(theta)); 165 const lon2 = lon1 + Math.atan2( 166 Math.sin(theta) * Math.sin(delta) * Math.cos(lat1), 167 Math.cos(delta) - Math.sin(lat1) * Math.sin(lat2) 168 ); 169 return [toDeg(lon2), toDeg(lat2)]; 170} 171 172/** 173 * Generate final viewline coordinates with offset 174 */ 175export function generateViewlineCoordinates(smoothedLats) { 176 const coords = []; 177 178 // Loop 0 to 360 inclusive to close the loop 179 for (let i = 0; i <= VIEWLINE_CONSTANTS.LONGITUDE_BINS; i++) { 180 const idx = i % VIEWLINE_CONSTANTS.LONGITUDE_BINS; 181 // Offset the line OFFSET_KM due south of the oval's edge 182 coords.push(...destination(i - 180, smoothedLats[idx], VIEWLINE_CONSTANTS.OFFSET_KM, 180)); 183 } 184 185 return coords; 186} 187 188/** 189 * Main viewline calculation function 190 */ 191export function calculateViewline(pts, threshold = 2, reduceForDaylight = false, lastUpdateTime = null) { 192 try { 193 if (!pts?.length) { 194 console.log("[Viewline] No input points provided."); 195 return null; 196 } 197 198 // Step 1: Bin points by longitude 199 const { bins, filledBins, validPointCount } = binPointsByLongitude(pts, threshold, reduceForDaylight, lastUpdateTime); 200 201 console.log(`[Viewline] Stats: Input: ${pts.length} | Valid > ${threshold}%: ${validPointCount} | Bins: ${filledBins}/360`); 202 203 if (filledBins < VIEWLINE_CONSTANTS.MIN_FILLED_BINS) { 204 console.warn("[Viewline] Not enough data spread across longitudes."); 205 return null; 206 } 207 208 // Step 2: Fill gaps 209 const interpolated = interpolateGaps(bins); 210 211 // Step 3: Smooth 212 const smoothed = smoothLatitudes(interpolated); 213 214 // Step 4: Generate coordinates 215 const coords = generateViewlineCoordinates(smoothed); 216 217 console.log(`[Viewline] Sample Lats: 0°: ${smoothed[0]?.toFixed(2)}, 90°E: ${smoothed[90]?.toFixed(2)}, 180°: ${smoothed[180]?.toFixed(2)}`); 218 219 return coords; 220 221 } catch (err) { 222 console.error("[Viewline] Error calculating:", err); 223 return null; 224 } 225}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.