diff --git a/src/plugins/layerRegistry.js b/src/plugins/layerRegistry.js index d39ffdb..4f2fe6d 100644 --- a/src/plugins/layerRegistry.js +++ b/src/plugins/layerRegistry.js @@ -6,12 +6,14 @@ import * as WXRadarPlugin from './layers/useWXRadar.js'; import * as EarthquakesPlugin from './layers/useEarthquakes.js'; import * as AuroraPlugin from './layers/useAurora.js'; import * as WSPRPlugin from './layers/useWSPR.js'; +import * as GrayLinePlugin from './layers/useGrayLine.js'; const layerPlugins = [ WXRadarPlugin, EarthquakesPlugin, AuroraPlugin, WSPRPlugin, + GrayLinePlugin, ]; export function getAllLayers() { diff --git a/src/plugins/layers/useGrayLine.js b/src/plugins/layers/useGrayLine.js new file mode 100644 index 0000000..cf7b17e --- /dev/null +++ b/src/plugins/layers/useGrayLine.js @@ -0,0 +1,601 @@ +import { useState, useEffect, useRef } from 'react'; + +/** + * Gray Line Propagation Overlay Plugin v1.0.0 + * + * 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 + * + * 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.0' +}; + +// 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; +} + +// Generate terminator line for a specific solar altitude +function generateTerminatorLine(date, solarAltitude = 0, numPoints = 360) { + const points = []; + const { declination } = calculateSolarPosition(date); + + for (let i = 0; i <= numPoints; i++) { + const lon = (i / numPoints) * 360 - 180; + + // Calculate latitude where sun is at specified altitude + const hourAngle = calculateHourAngle(date, lon); + const haRad = hourAngle * Math.PI / 180; + const decRad = declination * Math.PI / 180; + const altRad = solarAltitude * Math.PI / 180; + + // Solve for latitude using solar altitude equation + const cosHA = Math.cos(haRad); + const sinDec = Math.sin(decRad); + const cosDec = Math.cos(decRad); + const sinAlt = Math.sin(altRad); + + // lat = arcsin((sin(alt) - sin(dec) * sin(lat)) / (cos(dec) * cos(lat))) + // Simplified: lat where sun altitude equals target + const numerator = sinAlt - sinDec * Math.sin(0); // approximation + const denominator = cosDec * cosHA; + + let lat; + if (Math.abs(denominator) < 0.001) { + lat = declination > 0 ? 90 : -90; + } else { + const tanLat = (sinAlt - sinDec * Math.sin(declination * Math.PI / 180)) / denominator; + lat = Math.atan(tanLat) * 180 / Math.PI; + + // More accurate calculation + const cosLat = Math.cos(lat * Math.PI / 180); + const sinLat = Math.sin(lat * Math.PI / 180); + const recalc = sinLat * sinDec + cosLat * cosDec * cosHA; + lat = Math.asin(Math.max(-1, Math.min(1, recalc))) * 180 / Math.PI; + } + + // Clamp latitude + lat = Math.max(-90, Math.min(90, lat)); + + if (isFinite(lat) && isFinite(lon)) { + 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.3); + + 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 = ` +