Merge pull request #36 from trancen/feature/plugin-system

Add extensible plugin system for map layers
pull/65/head
accius 2 days ago committed by GitHub
commit 6f5b4e9c95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,16 @@
/**
* PluginLayer Component
* Renders a single plugin layer using its hook
*/
import React from 'react';
export const PluginLayer = ({ plugin, enabled, opacity, map }) => {
// Call the plugin's hook (this is allowed because it's in a component)
const result = plugin.hook({ enabled, opacity, map });
// Plugin hook handles its own rendering to the map
// This component doesn't render anything to the DOM
return null;
};
export default PluginLayer;

@ -1,6 +1,6 @@
/** /**
* SettingsPanel Component * SettingsPanel Component
* Full settings modal matching production version * Full settings modal with map layer controls
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { calculateGridSquare } from '../utils/geo.js'; import { calculateGridSquare } from '../utils/geo.js';
@ -14,6 +14,10 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
const [layout, setLayout] = useState(config?.layout || 'modern'); const [layout, setLayout] = useState(config?.layout || 'modern');
const [dxClusterSource, setDxClusterSource] = useState(config?.dxClusterSource || 'dxspider-proxy'); const [dxClusterSource, setDxClusterSource] = useState(config?.dxClusterSource || 'dxspider-proxy');
// Layer controls
const [layers, setLayers] = useState([]);
const [activeTab, setActiveTab] = useState('station');
useEffect(() => { useEffect(() => {
if (config) { if (config) {
setCallsign(config.callsign || ''); setCallsign(config.callsign || '');
@ -22,19 +26,33 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
setTheme(config.theme || 'dark'); setTheme(config.theme || 'dark');
setLayout(config.layout || 'modern'); setLayout(config.layout || 'modern');
setDxClusterSource(config.dxClusterSource || 'dxspider-proxy'); setDxClusterSource(config.dxClusterSource || 'dxspider-proxy');
// Use locator from config, or calculate from coordinates if (config.location?.lat && config.location?.lon) {
if (config.locator) {
setGridSquare(config.locator);
} else if (config.location?.lat && config.location?.lon) {
setGridSquare(calculateGridSquare(config.location.lat, config.location.lon)); setGridSquare(calculateGridSquare(config.location.lat, config.location.lon));
} }
} }
}, [config, isOpen]); }, [config, isOpen]);
// Update lat/lon when grid square changes // Load layers when panel opens
useEffect(() => {
if (isOpen && window.hamclockLayerControls) {
setLayers(window.hamclockLayerControls.layers || []);
}
}, [isOpen]);
// Refresh layers periodically
useEffect(() => {
if (isOpen && activeTab === 'layers') {
const interval = setInterval(() => {
if (window.hamclockLayerControls) {
setLayers([...window.hamclockLayerControls.layers]);
}
}, 200);
return () => clearInterval(interval);
}
}, [isOpen, activeTab]);
const handleGridChange = (grid) => { const handleGridChange = (grid) => {
setGridSquare(grid.toUpperCase()); setGridSquare(grid.toUpperCase());
// Parse grid square to lat/lon if valid (6 char)
if (grid.length >= 4) { if (grid.length >= 4) {
const parsed = parseGridSquare(grid); const parsed = parseGridSquare(grid);
if (parsed) { if (parsed) {
@ -44,7 +62,6 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
} }
}; };
// Parse grid square to coordinates
const parseGridSquare = (grid) => { const parseGridSquare = (grid) => {
grid = grid.toUpperCase(); grid = grid.toUpperCase();
if (grid.length < 4) return null; if (grid.length < 4) return null;
@ -67,7 +84,6 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
return { lat, lon }; return { lat, lon };
}; };
// Update grid when lat/lon changes
useEffect(() => { useEffect(() => {
if (lat && lon) { if (lat && lon) {
setGridSquare(calculateGridSquare(lat, lon)); setGridSquare(calculateGridSquare(lat, lon));
@ -91,11 +107,41 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
} }
}; };
const handleToggleLayer = (layerId) => {
if (window.hamclockLayerControls) {
const layer = layers.find(l => l.id === layerId);
const newEnabledState = !layer.enabled;
// Update the control
window.hamclockLayerControls.toggleLayer(layerId, newEnabledState);
// Force immediate UI update
setLayers(prevLayers =>
prevLayers.map(l =>
l.id === layerId ? { ...l, enabled: newEnabledState } : l
)
);
// Refresh after a short delay to get the updated state
setTimeout(() => {
if (window.hamclockLayerControls) {
setLayers([...window.hamclockLayerControls.layers]);
}
}, 100);
}
};
const handleOpacityChange = (layerId, opacity) => {
if (window.hamclockLayerControls) {
window.hamclockLayerControls.setOpacity(layerId, opacity);
setLayers([...window.hamclockLayerControls.layers]);
}
};
const handleSave = () => { const handleSave = () => {
onSave({ onSave({
...config, ...config,
callsign: callsign.toUpperCase(), callsign: callsign.toUpperCase(),
locator: gridSquare.toUpperCase(),
location: { lat: parseFloat(lat), lon: parseFloat(lon) }, location: { lat: parseFloat(lat), lon: parseFloat(lon) },
theme, theme,
layout, layout,
@ -136,44 +182,68 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
border: '2px solid var(--accent-amber)', border: '2px solid var(--accent-amber)',
borderRadius: '12px', borderRadius: '12px',
padding: '24px', padding: '24px',
width: '420px', width: '520px',
maxHeight: '90vh', maxHeight: '90vh',
overflowY: 'auto' overflowY: 'auto'
}}> }}>
<h2 style={{ <h2 style={{
color: 'var(--accent-cyan)', color: 'var(--accent-cyan)',
marginTop: 0, marginTop: 0,
marginBottom: '16px', marginBottom: '24px',
textAlign: 'center', textAlign: 'center',
fontFamily: 'Orbitron, monospace', fontFamily: 'Orbitron, monospace',
fontSize: '20px' fontSize: '20px'
}}> }}>
Station Settings Settings
</h2> </h2>
{/* First-time setup banner */} {/* Tab Navigation */}
{(config?.configIncomplete || config?.callsign === 'N0CALL' || !config?.locator) && (
<div style={{ <div style={{
background: 'rgba(255, 193, 7, 0.15)', display: 'flex',
border: '1px solid var(--accent-amber)', gap: '8px',
borderRadius: '8px', marginBottom: '24px',
padding: '12px 16px', borderBottom: '1px solid var(--border-color)',
marginBottom: '20px', paddingBottom: '12px'
fontSize: '13px'
}}> }}>
<div style={{ color: 'var(--accent-amber)', fontWeight: '700', marginBottom: '6px' }}> <button
👋 Welcome to OpenHamClock! onClick={() => setActiveTab('station')}
</div> style={{
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.5 }}> flex: 1,
Please enter your callsign and grid square to get started. padding: '10px',
Your settings will be saved in your browser. background: activeTab === 'station' ? 'var(--accent-amber)' : 'transparent',
</div> border: 'none',
<div style={{ color: 'var(--text-muted)', fontSize: '11px', marginTop: '8px' }}> borderRadius: '6px 6px 0 0',
💡 Tip: For permanent config, copy <code style={{ background: 'var(--bg-tertiary)', padding: '2px 4px', borderRadius: '3px' }}>.env.example</code> to <code style={{ background: 'var(--bg-tertiary)', padding: '2px 4px', borderRadius: '3px' }}>.env</code> and set CALLSIGN and LOCATOR color: activeTab === 'station' ? '#000' : 'var(--text-secondary)',
</div> fontSize: '13px',
cursor: 'pointer',
fontWeight: activeTab === 'station' ? '700' : '400',
fontFamily: 'JetBrains Mono, monospace'
}}
>
📡 Station
</button>
<button
onClick={() => setActiveTab('layers')}
style={{
flex: 1,
padding: '10px',
background: activeTab === 'layers' ? 'var(--accent-amber)' : 'transparent',
border: 'none',
borderRadius: '6px 6px 0 0',
color: activeTab === 'layers' ? '#000' : 'var(--text-secondary)',
fontSize: '13px',
cursor: 'pointer',
fontWeight: activeTab === 'layers' ? '700' : '400',
fontFamily: 'JetBrains Mono, monospace'
}}
>
🗺 Map Layers
</button>
</div> </div>
)}
{/* Station Settings Tab */}
{activeTab === 'station' && (
<>
{/* Callsign */} {/* Callsign */}
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}> <label style={{ display: 'block', marginBottom: '6px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
@ -233,8 +303,10 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
<input <input
type="number" type="number"
step="0.000001" step="0.000001"
value={lat} // value={lat}
onChange={(e) => setLat(parseFloat(e.target.value))} // onChange={(e) => setLat(parseFloat(e.target.value))}
value={isNaN(lat) ? '' : lat}
onChange={(e) => setLat(parseFloat(e.target.value) || 0)}
style={{ style={{
width: '100%', width: '100%',
padding: '10px', padding: '10px',
@ -255,8 +327,10 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
<input <input
type="number" type="number"
step="0.000001" step="0.000001"
value={lon} // value={lon}
onChange={(e) => setLon(parseFloat(e.target.value))} // onChange={(e) => setLon(parseFloat(e.target.value))}
value={isNaN(lon) ? '' : lon}
onChange={(e) => setLon(parseFloat(e.target.value) || 0)}
style={{ style={{
width: '100%', width: '100%',
padding: '10px', padding: '10px',
@ -272,7 +346,6 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
</div> </div>
</div> </div>
{/* Use My Location button */}
<button <button
onClick={handleUseLocation} onClick={handleUseLocation}
style={{ style={{
@ -379,6 +452,111 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
Real-time DX Spider feed via our dedicated proxy service Real-time DX Spider feed via our dedicated proxy service
</div> </div>
</div> </div>
</>
)}
{/* Map Layers Tab */}
{activeTab === 'layers' && (
<div>
{layers.length > 0 ? (
layers.map(layer => (
<div key={layer.id} style={{
background: 'var(--bg-tertiary)',
border: `1px solid ${layer.enabled ? 'var(--accent-amber)' : 'var(--border-color)'}`,
borderRadius: '8px',
padding: '14px',
marginBottom: '12px'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
cursor: 'pointer',
flex: 1
}}>
<input
type="checkbox"
checked={layer.enabled}
onChange={() => handleToggleLayer(layer.id)}
style={{
width: '18px',
height: '18px',
cursor: 'pointer'
}}
/>
<span style={{ fontSize: '18px' }}>{layer.icon}</span>
<div>
<div style={{
color: layer.enabled ? 'var(--accent-amber)' : 'var(--text-primary)',
fontSize: '14px',
fontWeight: '600',
fontFamily: 'JetBrains Mono, monospace'
}}>
{layer.name}
</div>
{layer.description && (
<div style={{
fontSize: '11px',
color: 'var(--text-muted)',
marginTop: '2px'
}}>
{layer.description}
</div>
)}
</div>
</label>
<span style={{
fontSize: '11px',
textTransform: 'uppercase',
color: 'var(--text-secondary)',
background: 'var(--bg-hover)',
padding: '2px 8px',
borderRadius: '3px'
}}>
{layer.category}
</span>
</div>
{layer.enabled && (
<div style={{ paddingLeft: '38px', marginTop: '12px' }}>
<label style={{
display: 'block',
fontSize: '11px',
color: 'var(--text-muted)',
marginBottom: '6px',
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}>
Opacity: {Math.round(layer.opacity * 100)}%
</label>
<input
type="range"
min="0"
max="100"
value={layer.opacity * 100}
onChange={(e) => handleOpacityChange(layer.id, parseFloat(e.target.value) / 100)}
style={{
width: '100%',
cursor: 'pointer'
}}
/>
</div>
)}
</div>
))
) : (
<div style={{
textAlign: 'center',
padding: '40px 20px',
color: 'var(--text-muted)',
fontSize: '13px'
}}>
No map layers available
</div>
)}
</div>
)}
{/* Buttons */} {/* Buttons */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginTop: '24px' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginTop: '24px' }}>

@ -1,6 +1,6 @@
/** /**
* WorldMap Component * WorldMap Component
* Leaflet map with DE/DX markers, terminator, DX paths, POTA, satellites * Leaflet map with DE/DX markers, terminator, DX paths, POTA, satellites, PSKReporter
*/ */
import React, { useRef, useEffect, useState } from 'react'; import React, { useRef, useEffect, useState } from 'react';
import { MAP_STYLES } from '../utils/config.js'; import { MAP_STYLES } from '../utils/config.js';
@ -12,6 +12,10 @@ import {
} from '../utils/geo.js'; } from '../utils/geo.js';
import { filterDXPaths, getBandColor } from '../utils/callsign.js'; import { filterDXPaths, getBandColor } from '../utils/callsign.js';
import { getAllLayers } from '../plugins/layerRegistry.js';
import PluginLayer from './PluginLayer.jsx';
export const WorldMap = ({ export const WorldMap = ({
deLocation, deLocation,
dxLocation, dxLocation,
@ -48,6 +52,10 @@ export const WorldMap = ({
const satTracksRef = useRef([]); const satTracksRef = useRef([]);
const pskMarkersRef = useRef([]); const pskMarkersRef = useRef([]);
// Plugin system refs and state
const pluginLayersRef = useRef({});
const [pluginLayerStates, setPluginLayerStates] = useState({});
// Load map style from localStorage // Load map style from localStorage
const getStoredMapSettings = () => { const getStoredMapSettings = () => {
try { try {
@ -419,6 +427,84 @@ export const WorldMap = ({
} }
}, [satellites, showSatellites]); }, [satellites, showSatellites]);
// Plugin layer system - properly load saved states
useEffect(() => {
if (!mapInstanceRef.current) return;
try {
const availableLayers = getAllLayers();
const settings = getStoredMapSettings();
const savedLayers = settings.layers || {};
// Build initial states from localStorage
const initialStates = {};
availableLayers.forEach(layerDef => {
// Use saved state if it exists, otherwise use defaults
if (savedLayers[layerDef.id]) {
initialStates[layerDef.id] = savedLayers[layerDef.id];
} else {
initialStates[layerDef.id] = {
enabled: layerDef.defaultEnabled,
opacity: layerDef.defaultOpacity
};
}
});
// Initialize state ONLY on first mount (when empty)
if (Object.keys(pluginLayerStates).length === 0) {
console.log('Loading saved layer states:', initialStates);
setPluginLayerStates(initialStates);
}
// Expose controls for SettingsPanel
window.hamclockLayerControls = {
layers: availableLayers.map(l => ({
...l,
enabled: pluginLayerStates[l.id]?.enabled ?? initialStates[l.id]?.enabled ?? l.defaultEnabled,
opacity: pluginLayerStates[l.id]?.opacity ?? initialStates[l.id]?.opacity ?? l.defaultOpacity
})),
toggleLayer: (id, enabled) => {
console.log(`Toggle layer ${id}:`, enabled);
const settings = getStoredMapSettings();
const layers = settings.layers || {};
layers[id] = {
enabled: enabled,
opacity: layers[id]?.opacity ?? 0.6
};
localStorage.setItem('openhamclock_mapSettings', JSON.stringify({ ...settings, layers }));
console.log('Saved to localStorage:', layers);
setPluginLayerStates(prev => ({
...prev,
[id]: {
...prev[id],
enabled: enabled
}
}));
},
setOpacity: (id, opacity) => {
console.log(`Set opacity ${id}:`, opacity);
const settings = getStoredMapSettings();
const layers = settings.layers || {};
layers[id] = {
enabled: layers[id]?.enabled ?? false,
opacity: opacity
};
localStorage.setItem('openhamclock_mapSettings', JSON.stringify({ ...settings, layers }));
console.log('Saved to localStorage:', layers);
setPluginLayerStates(prev => ({
...prev,
[id]: {
...prev[id],
opacity: opacity
}
}));
}
};
} catch (err) {
console.error('Plugin system error:', err);
}
}, [pluginLayerStates]);
// Update PSKReporter markers // Update PSKReporter markers
useEffect(() => { useEffect(() => {
if (!mapInstanceRef.current) return; if (!mapInstanceRef.current) return;
@ -488,6 +574,17 @@ export const WorldMap = ({
<div style={{ position: 'relative', height: '100%', minHeight: '200px' }}> <div style={{ position: 'relative', height: '100%', minHeight: '200px' }}>
<div ref={mapRef} style={{ height: '100%', width: '100%', borderRadius: '8px' }} /> <div ref={mapRef} style={{ height: '100%', width: '100%', borderRadius: '8px' }} />
{/* Render all plugin layers */}
{mapInstanceRef.current && getAllLayers().map(layerDef => (
<PluginLayer
key={layerDef.id}
plugin={layerDef}
enabled={pluginLayerStates[layerDef.id]?.enabled || false}
opacity={pluginLayerStates[layerDef.id]?.opacity || layerDef.defaultOpacity}
map={mapInstanceRef.current}
/>
))}
{/* Map style dropdown */} {/* Map style dropdown */}
<select <select
value={mapStyle} value={mapStyle}
@ -614,4 +711,5 @@ export const WorldMap = ({
); );
}; };
export default WorldMap; export default WorldMap;

File diff suppressed because it is too large Load Diff

@ -0,0 +1,32 @@
/**
* Layer Plugin Registry
* Only Weather Radar for now
*/
import * as WXRadarPlugin from './layers/useWXRadar.js';
import * as EarthquakesPlugin from './layers/useEarthquakes.js';
const layerPlugins = [
WXRadarPlugin,
EarthquakesPlugin,
];
export function getAllLayers() {
return layerPlugins
.filter(plugin => plugin.metadata && plugin.useLayer)
.map(plugin => ({
id: plugin.metadata.id,
name: plugin.metadata.name,
description: plugin.metadata.description,
icon: plugin.metadata.icon,
defaultEnabled: plugin.metadata.defaultEnabled || false,
defaultOpacity: plugin.metadata.defaultOpacity || 0.6,
category: plugin.metadata.category || 'overlay',
hook: plugin.useLayer
}));
}
export function getLayerById(layerId) {
const layers = getAllLayers();
return layers.find(layer => layer.id === layerId) || null;
}

@ -0,0 +1,145 @@
import { useState, useEffect } from 'react';
//Scaled markers - Bigger circles for stronger quakes
//Color-coded by magnitude:
//Yellow: M2.5-3 (minor)
//Orange: M3-4 (light)
//Deep Orange: M4-5 (moderate)
//Red: M5-6 (strong)
//Dark Red: M6-7 (major)
//Very Dark Red: M7+ (great)
export const metadata = {
id: 'earthquakes',
name: 'Earthquakes',
description: 'Live USGS earthquake data (M2.5+ from last 24 hours)',
icon: '🌋',
category: 'geology',
defaultEnabled: false,
defaultOpacity: 0.9,
version: '1.0.0'
};
export function useLayer({ enabled = false, opacity = 0.9, map = null }) {
const [markersRef, setMarkersRef] = useState([]);
const [earthquakeData, setEarthquakeData] = useState([]);
// Fetch earthquake data
useEffect(() => {
if (!enabled) return;
const fetchEarthquakes = async () => {
try {
// USGS GeoJSON feed - M2.5+ from last day
const response = await fetch(
'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_day.geojson'
);
const data = await response.json();
setEarthquakeData(data.features || []);
} catch (err) {
console.error('Earthquake data fetch error:', err);
}
};
fetchEarthquakes();
// Refresh every 5 minutes
const interval = setInterval(fetchEarthquakes, 300000);
return () => clearInterval(interval);
}, [enabled]);
// Add/remove markers
useEffect(() => {
if (!map || typeof L === 'undefined') return;
// Clear old markers
markersRef.forEach(marker => {
try {
map.removeLayer(marker);
} catch (e) {
// Already removed
}
});
setMarkersRef([]);
if (!enabled || earthquakeData.length === 0) return;
const newMarkers = [];
earthquakeData.forEach(quake => {
const coords = quake.geometry.coordinates;
const props = quake.properties;
const mag = props.mag;
const lat = coords[1];
const lon = coords[0];
const depth = coords[2];
// Skip if invalid coordinates
if (!lat || !lon || isNaN(lat) || isNaN(lon)) return;
// Calculate marker size based on magnitude (M2.5 = 8px, M7+ = 40px)
const size = Math.min(Math.max(mag * 4, 8), 40);
// Color based on magnitude
let color;
if (mag < 3) color = '#ffff00'; // Yellow - minor
else if (mag < 4) color = '#ffaa00'; // Orange - light
else if (mag < 5) color = '#ff6600'; // Deep orange - moderate
else if (mag < 6) color = '#ff3300'; // Red - strong
else if (mag < 7) color = '#cc0000'; // Dark red - major
else color = '#990000'; // Very dark red - great
// Create circle marker
const circle = L.circleMarker([lat, lon], {
radius: size / 2,
fillColor: color,
color: '#fff',
weight: 2,
opacity: opacity,
fillOpacity: opacity * 0.7
});
// Format time
const time = new Date(props.time);
const timeStr = time.toLocaleString();
// Add popup with details
circle.bindPopup(`
<div style="font-family: 'JetBrains Mono', monospace; min-width: 200px;">
<div style="font-size: 16px; font-weight: bold; color: ${color}; margin-bottom: 8px;">
M${mag.toFixed(1)} ${props.type === 'earthquake' ? '🌋' : '⚡'}
</div>
<table style="font-size: 12px; width: 100%;">
<tr><td><b>Location:</b></td><td>${props.place || 'Unknown'}</td></tr>
<tr><td><b>Time:</b></td><td>${timeStr}</td></tr>
<tr><td><b>Depth:</b></td><td>${depth.toFixed(1)} km</td></tr>
<tr><td><b>Magnitude:</b></td><td>${mag.toFixed(1)}</td></tr>
<tr><td><b>Status:</b></td><td>${props.status || 'automatic'}</td></tr>
${props.tsunami ? '<tr><td colspan="2" style="color: red; font-weight: bold;">⚠️ TSUNAMI WARNING</td></tr>' : ''}
</table>
${props.url ? `<a href="${props.url}" target="_blank" style="color: #00aaff; font-size: 11px;">View Details →</a>` : ''}
</div>
`);
circle.addTo(map);
newMarkers.push(circle);
});
setMarkersRef(newMarkers);
return () => {
newMarkers.forEach(marker => {
try {
map.removeLayer(marker);
} catch (e) {
// Already removed
}
});
};
}, [enabled, earthquakeData, map, opacity]);
return {
markers: markersRef,
earthquakeCount: earthquakeData.length
};
}

@ -0,0 +1,88 @@
import { useState, useEffect } from 'react';
export const metadata = {
id: 'wxradar',
name: 'Weather Radar',
description: 'NEXRAD weather radar overlay for North America',
icon: '☁️',
category: 'weather',
defaultEnabled: false,
defaultOpacity: 0.6,
version: '1.0.0'
};
export function useLayer({ enabled = false, opacity = 0.6, map = null }) {
const [layerRef, setLayerRef] = useState(null);
const [radarTimestamp, setRadarTimestamp] = useState(Date.now());
const wmsConfig = {
url: 'https://mesonet.agron.iastate.edu/cgi-bin/wms/nexrad/n0r.cgi',
options: {
layers: 'nexrad-n0r-900913',
format: 'image/png',
transparent: true,
attribution: 'Weather data © Iowa State University Mesonet',
opacity: opacity,
zIndex: 200
}
};
// Add/remove layer
useEffect(() => {
if (!map || typeof L === 'undefined') return;
if (enabled && !layerRef) {
try {
const layer = L.tileLayer.wms(wmsConfig.url, wmsConfig.options);
layer.addTo(map);
setLayerRef(layer);
} catch (err) {
console.error('WXRadar error:', err);
}
} else if (!enabled && layerRef) {
map.removeLayer(layerRef);
setLayerRef(null);
}
return () => {
if (layerRef && map) {
try {
map.removeLayer(layerRef);
} catch (e) {
// Layer already removed
}
}
};
}, [enabled, map]);
// Update opacity
useEffect(() => {
if (layerRef) {
layerRef.setOpacity(opacity);
}
}, [opacity, layerRef]);
// Auto-refresh every 2 minutes
useEffect(() => {
if (!enabled) return;
const interval = setInterval(() => {
setRadarTimestamp(Date.now());
}, 120000);
return () => clearInterval(interval);
}, [enabled]);
// Force refresh
useEffect(() => {
if (layerRef && enabled) {
layerRef.setParams({ t: radarTimestamp }, false);
layerRef.redraw();
}
}, [radarTimestamp, layerRef, enabled]);
return {
layer: layerRef,
refresh: () => setRadarTimestamp(Date.now())
};
}
Loading…
Cancel
Save

Powered by TurnKey Linux.