import { useState, useEffect, useRef } from 'react'; /** * Gray Line Propagation Overlay Plugin v1.0.1 * * Features: * - Real-time solar terminator (day/night boundary) * - Twilight zones (civil, nautical, astronomical) * - Animated update every minute * - Enhanced propagation zone highlighting * - Color-coded by propagation potential * - Minimizable control panel * - Corrected sine wave calculation (v1.0.1) * * Use Case: Identify optimal times for long-distance DX contacts * The gray line provides enhanced HF propagation for several hours */ export const metadata = { id: 'grayline', name: 'Gray Line Propagation', description: 'Solar terminator with twilight zones for enhanced DX propagation', icon: '🌅', category: 'propagation', defaultEnabled: false, defaultOpacity: 0.5, version: '1.0.2' }; // Solar calculations based on astronomical algorithms function calculateSolarPosition(date) { const JD = dateToJulianDate(date); const T = (JD - 2451545.0) / 36525.0; // Julian centuries since J2000.0 // Mean longitude of the sun const L0 = (280.46646 + 36000.76983 * T + 0.0003032 * T * T) % 360; // Mean anomaly const M = (357.52911 + 35999.05029 * T - 0.0001537 * T * T) % 360; const MRad = M * Math.PI / 180; // Equation of center const C = (1.914602 - 0.004817 * T - 0.000014 * T * T) * Math.sin(MRad) + (0.019993 - 0.000101 * T) * Math.sin(2 * MRad) + 0.000289 * Math.sin(3 * MRad); // True longitude const trueLon = L0 + C; // Apparent longitude const omega = 125.04 - 1934.136 * T; const lambda = trueLon - 0.00569 - 0.00478 * Math.sin(omega * Math.PI / 180); // Obliquity of ecliptic const epsilon = 23.439291 - 0.0130042 * T; const epsilonRad = epsilon * Math.PI / 180; const lambdaRad = lambda * Math.PI / 180; // Solar declination const declination = Math.asin(Math.sin(epsilonRad) * Math.sin(lambdaRad)) * 180 / Math.PI; // Solar right ascension const RA = Math.atan2(Math.cos(epsilonRad) * Math.sin(lambdaRad), Math.cos(lambdaRad)) * 180 / Math.PI; return { declination, rightAscension: RA }; } function dateToJulianDate(date) { return (date.getTime() / 86400000) + 2440587.5; } // Calculate solar hour angle for a given longitude at a specific time function calculateHourAngle(date, longitude) { const JD = dateToJulianDate(date); const T = (JD - 2451545.0) / 36525.0; // Greenwich Mean Sidereal Time const GMST = (280.46061837 + 360.98564736629 * (JD - 2451545.0) + 0.000387933 * T * T - T * T * T / 38710000) % 360; const { rightAscension } = calculateSolarPosition(date); // Local hour angle const hourAngle = (GMST + longitude - rightAscension + 360) % 360; return hourAngle; } // Calculate solar altitude for a given position and time function calculateSolarAltitude(date, latitude, longitude) { const { declination } = calculateSolarPosition(date); const hourAngle = calculateHourAngle(date, longitude); const latRad = latitude * Math.PI / 180; const decRad = declination * Math.PI / 180; const haRad = hourAngle * Math.PI / 180; const sinAlt = Math.sin(latRad) * Math.sin(decRad) + Math.cos(latRad) * Math.cos(decRad) * Math.cos(haRad); const altitude = Math.asin(sinAlt) * 180 / Math.PI; return altitude; } // Split polyline at date line to avoid lines cutting across the map function splitAtDateLine(points) { if (points.length < 2) return [points]; // Check if line spans the full world (-180 to 180) const lons = points.map(p => p[1]); const minLon = Math.min(...lons); const maxLon = Math.max(...lons); const span = maxLon - minLon; console.log('🔍 splitAtDateLine debug:', { totalPoints: points.length, lonRange: `${minLon.toFixed(1)} to ${maxLon.toFixed(1)}`, span: span.toFixed(1) }); // If the line spans close to 360°, it wraps around the world // We need to split it at the ±180° boundary if (span > 350) { console.log('🔍 Full-world span detected, splitting at ±180°'); // Strategy: Create two segments that meet at ±180° longitude // Segment 1: Western hemisphere (-180° to slightly past 0°) // Segment 2: Eastern hemisphere (slightly before 0° to +180°) const westSegment = []; // Points from -180° to ~0° const eastSegment = []; // Points from ~0° to +180° // Sort points by longitude to ensure correct ordering const sortedPoints = [...points].sort((a, b) => a[1] - b[1]); // Find the midpoint longitude (should be around 0°) const midIndex = Math.floor(sortedPoints.length / 2); // Split at midpoint, with some overlap westSegment.push(...sortedPoints.slice(0, midIndex + 1)); eastSegment.push(...sortedPoints.slice(midIndex)); const segments = []; if (westSegment.length >= 2) segments.push(westSegment); if (eastSegment.length >= 2) segments.push(eastSegment); console.log('🔍 Split into segments:', segments.map(s => { const lons = s.map(p => p[1]); return { points: s.length, lonRange: `${Math.min(...lons).toFixed(1)} to ${Math.max(...lons).toFixed(1)}` }; })); return segments; } // Otherwise, check for sudden longitude jumps (traditional date line crossing) const segments = []; let currentSegment = [points[0]]; for (let i = 1; i < points.length; i++) { const prev = points[i - 1]; const curr = points[i]; const prevLon = prev[1]; const currLon = curr[1]; const lonDiff = Math.abs(currLon - prevLon); // If longitude jumps more than 180°, we've crossed the date line if (lonDiff > 180) { console.log(`🔍 Date line jump detected at index ${i}: ${prevLon.toFixed(1)}° → ${currLon.toFixed(1)}°`); segments.push(currentSegment); currentSegment = [curr]; } else { currentSegment.push(curr); } } if (currentSegment.length > 0) { segments.push(currentSegment); } console.log('🔍 splitAtDateLine result:', segments.length, 'segments'); return segments.filter(seg => seg.length >= 2); } // Generate terminator line for a specific solar altitude function generateTerminatorLine(date, solarAltitude = 0, numPoints = 360) { const points = []; const { declination } = calculateSolarPosition(date); const decRad = declination * Math.PI / 180; const altRad = solarAltitude * Math.PI / 180; // For each longitude, calculate the latitude where the sun is at the specified altitude for (let i = 0; i <= numPoints; i++) { const lon = (i / numPoints) * 360 - 180; const hourAngle = calculateHourAngle(date, lon); const haRad = hourAngle * Math.PI / 180; const cosHA = Math.cos(haRad); const sinDec = Math.sin(decRad); const cosDec = Math.cos(decRad); const sinAlt = Math.sin(altRad); let lat; // Check if solution exists (sun can reach this altitude at this longitude) // For terminator and twilight, check if |cos(HA) * cos(dec)| <= 1 - sin(alt) * sin(dec) const testValue = (sinAlt - sinDec * sinDec) / (cosDec * cosDec * cosHA * cosHA); if (Math.abs(declination) < 0.01) { // Near equinox: terminator is nearly straight along equator lat = 0; } else if (Math.abs(cosDec) < 0.001) { // Near solstice: sun is directly over tropic, skip this point continue; } else { // Standard case: calculate terminator latitude const tanDec = Math.tan(decRad); if (solarAltitude === 0) { // Terminator (sunrise/sunset line) // Formula: tan(lat) = -cos(HA) / tan(dec) if (Math.abs(tanDec) > 0.0001) { lat = Math.atan(-cosHA / tanDec) * 180 / Math.PI; } else { lat = 0; } } else { // Twilight zones (negative solar altitude) // Use Newton-Raphson iteration to solve for latitude // Equation: sin(lat) * sin(dec) + cos(lat) * cos(dec) * cos(HA) = sin(alt) // Initial guess based on terminator let testLat = Math.atan(-cosHA / tanDec); // Iterate to find solution let converged = false; for (let iter = 0; iter < 10; iter++) { const f = Math.sin(testLat) * sinDec + Math.cos(testLat) * cosDec * cosHA - sinAlt; const fPrime = Math.cos(testLat) * sinDec - Math.sin(testLat) * cosDec * cosHA; if (Math.abs(f) < 0.0001) { converged = true; break; } if (Math.abs(fPrime) > 0.0001) { testLat = testLat - f / fPrime; } else { break; } // Constrain to valid latitude range during iteration testLat = Math.max(-Math.PI/2, Math.min(Math.PI/2, testLat)); } // Only use the point if iteration converged if (!converged) { continue; } lat = testLat * 180 / Math.PI; } } // Strict clamping to valid latitude range lat = Math.max(-85, Math.min(85, lat)); // Only add point if it's valid and not at extreme latitude if (isFinite(lat) && isFinite(lon) && Math.abs(lat) < 85) { points.push([lat, lon]); } } return points; } // Make control panel draggable and minimizable function makeDraggable(element, storageKey) { if (!element) return; const saved = localStorage.getItem(storageKey); if (saved) { try { const { top, left } = JSON.parse(saved); element.style.position = 'fixed'; element.style.top = top + 'px'; element.style.left = left + 'px'; element.style.right = 'auto'; element.style.bottom = 'auto'; } catch (e) {} } else { const rect = element.getBoundingClientRect(); element.style.position = 'fixed'; element.style.top = rect.top + 'px'; element.style.left = rect.left + 'px'; element.style.right = 'auto'; element.style.bottom = 'auto'; } element.title = 'Hold CTRL and drag to reposition'; let isDragging = false; let startX, startY, startLeft, startTop; const updateCursor = (e) => { if (e.ctrlKey) { element.style.cursor = 'grab'; } else { element.style.cursor = 'default'; } }; element.addEventListener('mouseenter', updateCursor); element.addEventListener('mousemove', updateCursor); document.addEventListener('keydown', (e) => { if (e.key === 'Control') updateCursor(e); }); document.addEventListener('keyup', (e) => { if (e.key === 'Control') updateCursor(e); }); element.addEventListener('mousedown', function(e) { if (!e.ctrlKey) return; if (e.target.tagName === 'SELECT' || e.target.tagName === 'INPUT' || e.target.tagName === 'LABEL') { return; } isDragging = true; startX = e.clientX; startY = e.clientY; startLeft = element.offsetLeft; startTop = element.offsetTop; element.style.cursor = 'grabbing'; element.style.opacity = '0.8'; e.preventDefault(); }); document.addEventListener('mousemove', function(e) { if (!isDragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; element.style.left = (startLeft + dx) + 'px'; element.style.top = (startTop + dy) + 'px'; }); document.addEventListener('mouseup', function(e) { if (isDragging) { isDragging = false; element.style.opacity = '1'; updateCursor(e); localStorage.setItem(storageKey, JSON.stringify({ top: element.offsetTop, left: element.offsetLeft })); } }); } function addMinimizeToggle(element, storageKey) { if (!element) return; const minimizeKey = storageKey + '-minimized'; const header = element.querySelector('div:first-child'); if (!header) return; const content = Array.from(element.children).slice(1); const contentWrapper = document.createElement('div'); contentWrapper.className = 'grayline-panel-content'; content.forEach(child => contentWrapper.appendChild(child)); element.appendChild(contentWrapper); const minimizeBtn = document.createElement('span'); minimizeBtn.className = 'grayline-minimize-btn'; minimizeBtn.innerHTML = '▼'; minimizeBtn.style.cssText = ` float: right; cursor: pointer; user-select: none; padding: 0 4px; margin: -2px -4px 0 0; font-size: 10px; opacity: 0.7; transition: opacity 0.2s; `; minimizeBtn.title = 'Minimize/Maximize'; minimizeBtn.addEventListener('mouseenter', () => { minimizeBtn.style.opacity = '1'; }); minimizeBtn.addEventListener('mouseleave', () => { minimizeBtn.style.opacity = '0.7'; }); header.style.display = 'flex'; header.style.justifyContent = 'space-between'; header.style.alignItems = 'center'; header.appendChild(minimizeBtn); const isMinimized = localStorage.getItem(minimizeKey) === 'true'; if (isMinimized) { contentWrapper.style.display = 'none'; minimizeBtn.innerHTML = '▶'; element.style.cursor = 'pointer'; } const toggle = (e) => { if (e && e.ctrlKey) return; const isCurrentlyMinimized = contentWrapper.style.display === 'none'; if (isCurrentlyMinimized) { contentWrapper.style.display = 'block'; minimizeBtn.innerHTML = '▼'; element.style.cursor = 'default'; localStorage.setItem(minimizeKey, 'false'); } else { contentWrapper.style.display = 'none'; minimizeBtn.innerHTML = '▶'; element.style.cursor = 'pointer'; localStorage.setItem(minimizeKey, 'true'); } }; header.addEventListener('click', (e) => { if (e.target === header || e.target.tagName === 'DIV') { toggle(e); } }); minimizeBtn.addEventListener('click', (e) => { e.stopPropagation(); toggle(e); }); } export function useLayer({ enabled = false, opacity = 0.5, map = null }) { const [layers, setLayers] = useState([]); const [currentTime, setCurrentTime] = useState(new Date()); const [showTwilight, setShowTwilight] = useState(true); const [showEnhancedZone, setShowEnhancedZone] = useState(true); const [twilightOpacity, setTwilightOpacity] = useState(0.5); const controlRef = useRef(null); const updateIntervalRef = useRef(null); // Update time every minute useEffect(() => { if (!enabled) return; const updateTime = () => { setCurrentTime(new Date()); }; updateTime(); // Initial update updateIntervalRef.current = setInterval(updateTime, 60000); // Every minute return () => { if (updateIntervalRef.current) { clearInterval(updateIntervalRef.current); } }; }, [enabled]); // Create control panel useEffect(() => { if (!enabled || !map || controlRef.current) return; const GrayLineControl = L.Control.extend({ options: { position: 'topright' }, onAdd: function() { const container = L.DomUtil.create('div', 'grayline-control'); container.style.cssText = ` background: rgba(0, 0, 0, 0.9); padding: 12px; border-radius: 5px; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: white; box-shadow: 0 2px 8px rgba(0,0,0,0.3); min-width: 200px; `; const now = new Date(); const timeStr = now.toUTCString(); container.innerHTML = `
🌅 Gray Line
UTC TIME
${timeStr}
🌅 Gray line = enhanced HF propagation
Updates every minute
`; L.DomEvent.disableClickPropagation(container); L.DomEvent.disableScrollPropagation(container); return container; } }); const control = new GrayLineControl(); map.addControl(control); controlRef.current = control; setTimeout(() => { const container = document.querySelector('.grayline-control'); if (container) { makeDraggable(container, 'grayline-position'); addMinimizeToggle(container, 'grayline-position'); } // Add event listeners const twilightCheck = document.getElementById('grayline-twilight'); const enhancedCheck = document.getElementById('grayline-enhanced'); const twilightOpacitySlider = document.getElementById('grayline-twilight-opacity'); const twilightOpacityValue = document.getElementById('twilight-opacity-value'); if (twilightCheck) { twilightCheck.addEventListener('change', (e) => setShowTwilight(e.target.checked)); } if (enhancedCheck) { enhancedCheck.addEventListener('change', (e) => setShowEnhancedZone(e.target.checked)); } if (twilightOpacitySlider) { twilightOpacitySlider.addEventListener('input', (e) => { const value = parseInt(e.target.value) / 100; setTwilightOpacity(value); if (twilightOpacityValue) twilightOpacityValue.textContent = e.target.value; }); } }, 150); }, [enabled, map]); // Update time display useEffect(() => { const timeElement = document.getElementById('grayline-time'); if (timeElement && enabled) { timeElement.textContent = currentTime.toUTCString(); } }, [currentTime, enabled]); // Render gray line and twilight zones useEffect(() => { if (!map || !enabled) return; // Clear old layers layers.forEach(layer => { try { map.removeLayer(layer); } catch (e) {} }); const newLayers = []; // Main terminator (solar altitude = 0°) const terminator = generateTerminatorLine(currentTime, 0, 360); const terminatorSegments = splitAtDateLine(terminator); terminatorSegments.forEach(segment => { const terminatorLine = L.polyline(segment, { color: '#ff6600', weight: 3, opacity: opacity * 0.8, dashArray: '10, 5' }); terminatorLine.bindPopup(`
🌅 Solar Terminator
Sun altitude: 0°
Enhanced HF propagation zone
UTC: ${currentTime.toUTCString()}
`); terminatorLine.addTo(map); newLayers.push(terminatorLine); }); // Enhanced DX zone (±5° from terminator) if (showEnhancedZone) { const enhancedUpper = generateTerminatorLine(currentTime, 5, 360); const enhancedLower = generateTerminatorLine(currentTime, -5, 360); // Only create polygon if we have valid points if (enhancedUpper.length > 2 && enhancedLower.length > 2) { // Split both upper and lower lines at date line const upperSegments = splitAtDateLine(enhancedUpper); const lowerSegments = splitAtDateLine(enhancedLower); console.log('🔶 Enhanced DX Zone segments:', { upperCount: upperSegments.length, lowerCount: lowerSegments.length, upperSegmentLengths: upperSegments.map(s => s.length), lowerSegmentLengths: lowerSegments.map(s => s.length) }); // Create polygon for each corresponding segment pair // Both upper and lower should have same number of segments const numSegments = Math.min(upperSegments.length, lowerSegments.length); for (let i = 0; i < numSegments; i++) { const upperSeg = upperSegments[i]; const lowerSeg = lowerSegments[i]; if (upperSeg.length > 1 && lowerSeg.length > 1) { // Create polygon from upper segment + reversed lower segment // This creates a closed shape between the two lines const enhancedZone = [...upperSeg, ...lowerSeg.slice().reverse()]; // Debug: Show longitude range of this polygon const polyLons = enhancedZone.map(p => p[1]); const polyMinLon = Math.min(...polyLons); const polyMaxLon = Math.max(...polyLons); console.log(`🔶 Creating Enhanced DX polygon segment ${i+1}/${numSegments}:`, { upperPoints: upperSeg.length, lowerPoints: lowerSeg.length, totalPolygonPoints: enhancedZone.length, lonRange: `${polyMinLon.toFixed(1)} to ${polyMaxLon.toFixed(1)}` }); const enhancedPoly = L.polygon(enhancedZone, { color: '#ffaa00', fillColor: '#ffaa00', fillOpacity: opacity * 0.15, weight: 1, opacity: opacity * 0.3 }); enhancedPoly.bindPopup(`
⭐ Enhanced DX Zone
Best HF propagation window
±5° from terminator
Ideal for long-distance contacts
`); enhancedPoly.addTo(map); newLayers.push(enhancedPoly); } } } } // Twilight zones if (showTwilight) { // Civil twilight (sun altitude -6°) const civilTwilight = generateTerminatorLine(currentTime, -6, 360); const civilSegments = splitAtDateLine(civilTwilight); civilSegments.forEach(segment => { const civilLine = L.polyline(segment, { color: '#4488ff', weight: 2, opacity: twilightOpacity * 0.6, dashArray: '5, 5' }); civilLine.bindPopup(`
🌆 Civil Twilight
Sun altitude: -6°
Good propagation conditions
`); civilLine.addTo(map); newLayers.push(civilLine); }); // Nautical twilight (sun altitude -12°) const nauticalTwilight = generateTerminatorLine(currentTime, -12, 360); const nauticalSegments = splitAtDateLine(nauticalTwilight); nauticalSegments.forEach(segment => { const nauticalLine = L.polyline(segment, { color: '#6666ff', weight: 1.5, opacity: twilightOpacity * 0.4, dashArray: '3, 3' }); nauticalLine.bindPopup(`
🌃 Nautical Twilight
Sun altitude: -12°
Moderate propagation
`); nauticalLine.addTo(map); newLayers.push(nauticalLine); }); // Astronomical twilight (sun altitude -18°) const astroTwilight = generateTerminatorLine(currentTime, -18, 360); const astroSegments = splitAtDateLine(astroTwilight); astroSegments.forEach(segment => { const astroLine = L.polyline(segment, { color: '#8888ff', weight: 1, opacity: twilightOpacity * 0.3, dashArray: '2, 2' }); astroLine.bindPopup(`
🌌 Astronomical Twilight
Sun altitude: -18°
Transition to night propagation
`); astroLine.addTo(map); newLayers.push(astroLine); }); } setLayers(newLayers); console.log(`[Gray Line] Rendered terminator and ${showTwilight ? '3 twilight zones' : 'no twilight'} at ${currentTime.toUTCString()}`); return () => { newLayers.forEach(layer => { try { map.removeLayer(layer); } catch (e) {} }); }; }, [map, enabled, currentTime, opacity, showTwilight, showEnhancedZone, twilightOpacity]); // Cleanup on disable useEffect(() => { if (!enabled && map && controlRef.current) { try { map.removeControl(controlRef.current); console.log('[Gray Line] Removed control'); } catch (e) { console.error('[Gray Line] Error removing control:', e); } controlRef.current = null; layers.forEach(layer => { try { map.removeLayer(layer); } catch (e) {} }); setLayers([]); } }, [enabled, map, layers]); return { layers, currentTime, showTwilight, showEnhancedZone }; }