PageSourceSearch

https://aurora-c9c22.web.app/js/viewlineCalculator.js?v=e9bdb52ba2

js aurora-c9c22.web.app collected 2026-10-03 12:18:03 UTC 7,967 bytes, 225 lines download raw bytes

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.