Merge pull request #54 from accius/Modular-Staging

Modular staging
pull/65/head
accius 2 days ago committed by GitHub
commit fe1011d347
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -99,6 +99,10 @@ const App = () => {
const [showSettings, setShowSettings] = useState(false);
const [showDXFilters, setShowDXFilters] = useState(false);
const [showPSKFilters, setShowPSKFilters] = useState(false);
const [weatherExpanded, setWeatherExpanded] = useState(false);
const [tempUnit, setTempUnit] = useState(() => {
try { return localStorage.getItem('openhamclock_tempUnit') || 'F'; } catch { return 'F'; }
});
const [isFullscreen, setIsFullscreen] = useState(false);
// Map layer visibility
@ -208,7 +212,7 @@ const App = () => {
const propagation = usePropagation(config.location, dxLocation);
const mySpots = useMySpots(config.callsign);
const satellites = useSatellites(config.location);
const localWeather = useLocalWeather(config.location);
const localWeather = useLocalWeather(config.location, tempUnit);
const pskReporter = usePSKReporter(config.callsign, { minutes: 15, enabled: config.callsign !== 'N0CALL' });
const wsjtx = useWSJTX();
@ -599,7 +603,7 @@ const App = () => {
{/* LEFT SIDEBAR */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', overflowY: 'auto', overflowX: 'hidden' }}>
{/* DE Location */}
{/* DE Location + Weather */}
<div className="panel" style={{ padding: '14px', flex: '0 0 auto' }}>
<div style={{ fontSize: '14px', color: 'var(--accent-cyan)', fontWeight: '700', marginBottom: '10px' }}>📍 DE - YOUR LOCATION</div>
<div style={{ fontFamily: 'JetBrains Mono', fontSize: '14px' }}>
@ -612,6 +616,173 @@ const App = () => {
<span style={{ color: 'var(--accent-purple)', fontWeight: '600' }}>{deSunTimes.sunset}</span>
</div>
</div>
{/* Local Weather — compact by default, click to expand */}
{localWeather.data && (() => {
const w = localWeather.data;
const deg = `°${w.tempUnit || tempUnit}`;
const wind = w.windUnit || 'mph';
const vis = w.visUnit || 'mi';
return (
<div style={{ marginTop: '12px', borderTop: '1px solid var(--border-color)', paddingTop: '10px' }}>
{/* Compact summary row — always visible */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
onClick={() => setWeatherExpanded(!weatherExpanded)}
style={{
display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer',
userSelect: 'none', flex: 1, minWidth: 0,
}}
>
<span style={{ fontSize: '20px', lineHeight: 1 }}>{w.icon}</span>
<span style={{ fontSize: '18px', fontWeight: '700', color: 'var(--text-primary)', fontFamily: 'Orbitron, monospace' }}>
{w.temp}{deg}
</span>
<span style={{ fontSize: '11px', color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{w.description}</span>
<span style={{ fontSize: '11px', color: 'var(--text-muted)', fontFamily: 'JetBrains Mono, monospace' }}>
💨{w.windSpeed}
</span>
<span style={{
fontSize: '10px', color: 'var(--text-muted)',
transform: weatherExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s',
}}></span>
</div>
{/* F/C toggle */}
<button
onClick={(e) => {
e.stopPropagation();
const next = tempUnit === 'F' ? 'C' : 'F';
setTempUnit(next);
try { localStorage.setItem('openhamclock_tempUnit', next); } catch {}
}}
style={{
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
color: 'var(--text-secondary)',
fontSize: '10px',
padding: '1px 5px',
borderRadius: '3px',
cursor: 'pointer',
fontFamily: 'JetBrains Mono, monospace',
fontWeight: '600',
flexShrink: 0,
}}
title={`Switch to °${tempUnit === 'F' ? 'C' : 'F'}`}
>
°{tempUnit === 'F' ? 'C' : 'F'}
</button>
</div>
{/* Expanded details */}
{weatherExpanded && (
<div style={{ marginTop: '10px' }}>
{/* Feels like + hi/lo */}
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '11px', marginBottom: '8px', fontFamily: 'JetBrains Mono, monospace' }}>
{w.feelsLike !== w.temp && (
<span style={{ color: 'var(--text-muted)' }}>Feels like {w.feelsLike}{deg}</span>
)}
{w.todayHigh != null && (
<span style={{ color: 'var(--text-muted)', marginLeft: 'auto' }}>
<span style={{ color: 'var(--accent-amber)' }}>{w.todayHigh}°</span>
{' '}
<span style={{ color: 'var(--accent-blue)' }}>{w.todayLow}°</span>
</span>
)}
</div>
{/* Detail grid */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '6px 12px',
fontSize: '11px',
fontFamily: 'JetBrains Mono, monospace',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-muted)' }}>💨 Wind</span>
<span style={{ color: 'var(--text-secondary)' }}>{w.windDir} {w.windSpeed} {wind}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-muted)' }}>💧 Humidity</span>
<span style={{ color: 'var(--text-secondary)' }}>{w.humidity}%</span>
</div>
{w.windGusts > w.windSpeed + 5 && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-muted)' }}>🌬 Gusts</span>
<span style={{ color: 'var(--text-secondary)' }}>{w.windGusts} {wind}</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-muted)' }}>🌡 Dew Pt</span>
<span style={{ color: 'var(--text-secondary)' }}>{w.dewPoint}{deg}</span>
</div>
{w.pressure && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-muted)' }}>🔵 Pressure</span>
<span style={{ color: 'var(--text-secondary)' }}>{w.pressure} hPa</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-muted)' }}> Clouds</span>
<span style={{ color: 'var(--text-secondary)' }}>{w.cloudCover}%</span>
</div>
{w.visibility && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-muted)' }}>👁 Vis</span>
<span style={{ color: 'var(--text-secondary)' }}>{w.visibility} {vis}</span>
</div>
)}
{w.uvIndex > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-muted)' }}> UV</span>
<span style={{ color: w.uvIndex >= 8 ? '#ef4444' : w.uvIndex >= 6 ? '#f97316' : w.uvIndex >= 3 ? '#eab308' : 'var(--text-secondary)' }}>
{w.uvIndex.toFixed(1)}
</span>
</div>
)}
</div>
{/* 3-Day Forecast */}
{w.daily?.length > 0 && (
<div style={{
marginTop: '10px',
paddingTop: '8px',
borderTop: '1px solid var(--border-color)',
}}>
<div style={{ fontSize: '10px', color: 'var(--text-muted)', marginBottom: '6px', fontWeight: '600' }}>FORECAST</div>
<div style={{ display: 'flex', gap: '4px' }}>
{w.daily.map((day, i) => (
<div key={i} style={{
flex: 1,
textAlign: 'center',
padding: '6px 2px',
background: 'var(--bg-tertiary)',
borderRadius: '4px',
fontSize: '10px',
}}>
<div style={{ color: 'var(--text-muted)', fontWeight: '600', marginBottom: '2px' }}>{i === 0 ? 'Today' : day.date}</div>
<div style={{ fontSize: '16px', lineHeight: 1.2 }}>{day.icon}</div>
<div style={{ fontFamily: 'JetBrains Mono, monospace', marginTop: '2px' }}>
<span style={{ color: 'var(--accent-amber)' }}>{day.high}°</span>
<span style={{ color: 'var(--text-muted)' }}>/</span>
<span style={{ color: 'var(--accent-blue)' }}>{day.low}°</span>
</div>
{day.precipProb > 0 && (
<div style={{ color: 'var(--accent-blue)', fontSize: '9px', marginTop: '1px' }}>
💧{day.precipProb}%
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
})()}
</div>
{/* DX Location */}

@ -1,13 +1,14 @@
/**
* SolarPanel Component
* Cycles between: Solar Image Solar Indices X-Ray Flux Chart
* Cycles between: Solar Image Solar Indices X-Ray Flux Chart Lunar Phase
*/
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { getMoonPhase } from '../utils/geo.js';
const MODES = ['image', 'indices', 'xray'];
const MODE_LABELS = { image: 'SOLAR', indices: 'SOLAR INDICES', xray: 'X-RAY FLUX' };
const MODE_ICONS = { image: '📊', indices: '📈', xray: '🖼' };
const MODE_TITLES = { image: 'Show solar indices', indices: 'Show X-ray flux', xray: 'Show solar image' };
const MODES = ['image', 'indices', 'xray', 'lunar'];
const MODE_LABELS = { image: 'SOLAR', indices: 'SOLAR INDICES', xray: 'X-RAY FLUX', lunar: 'LUNAR' };
const MODE_ICONS = { image: '📊', indices: '📈', xray: '🌙', lunar: '☀' };
const MODE_TITLES = { image: 'Show solar indices', indices: 'Show X-ray flux', xray: 'Show lunar phase', lunar: 'Show solar image' };
// Flare class from flux value (W/m²)
const getFlareClass = (flux) => {
@ -245,6 +246,161 @@ export const SolarPanel = ({ solarIndices }) => {
);
};
// Lunar phase renderer
const renderLunar = () => {
const now = new Date();
const phase = getMoonPhase(now); // 0-1, 0=new, 0.5=full
const illumination = Math.round((1 - Math.cos(phase * 2 * Math.PI)) / 2 * 100);
// Phase name
let phaseName = 'New Moon';
if (phase >= 0.0625 && phase < 0.1875) phaseName = 'Waxing Crescent';
else if (phase >= 0.1875 && phase < 0.3125) phaseName = 'First Quarter';
else if (phase >= 0.3125 && phase < 0.4375) phaseName = 'Waxing Gibbous';
else if (phase >= 0.4375 && phase < 0.5625) phaseName = 'Full Moon';
else if (phase >= 0.5625 && phase < 0.6875) phaseName = 'Waning Gibbous';
else if (phase >= 0.6875 && phase < 0.8125) phaseName = 'Last Quarter';
else if (phase >= 0.8125 && phase < 0.9375) phaseName = 'Waning Crescent';
// Find next full moon & new moon by scanning forward
const findNextPhase = (targetPhase, label) => {
const d = new Date(now);
for (let i = 1; i <= 35; i++) {
d.setDate(d.getDate() + 1);
const p = getMoonPhase(d);
const diff = Math.abs(p - targetPhase);
if (diff < 0.018 || diff > 0.982) {
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
}
return '—';
};
const nextFull = findNextPhase(0.5, 'Full');
const nextNew = findNextPhase(0.0, 'New');
// SVG moon uses a crescent/gibbous mask technique
// phase 0=new(dark), 0.25=first quarter(right lit), 0.5=full(all lit), 0.75=last quarter(left lit)
const R = 60; // moon radius
const CX = 70, CY = 70;
// The terminator curve is an ellipse whose x-radius varies with phase
// At new moon (0): fully dark. At full (0.5): fully lit.
// phase 0-0.5: right side lit (waxing), 0.5-1: left side lit (waning)
const angle = phase * 2 * Math.PI;
const terminatorX = R * Math.cos(angle); // ranges from R (new) through 0 (quarter) to -R (full) and back
// Build the lit area path
// Right half arc (from top to bottom) is always an arc of radius R
// Left boundary (terminator) is an ellipse with rx = |terminatorX|
const buildMoonPath = () => {
// Lit portion: we draw two arcs the outer limb and the terminator
// For waxing (0 < phase < 0.5): right side is lit
// For waning (0.5 < phase < 1): left side is lit
if (phase < 0.01 || phase > 0.99) {
// New moon no lit area
return null;
}
if (phase > 0.49 && phase < 0.51) {
// Full moon entire circle lit
return `M${CX},${CY - R} A${R},${R} 0 1,1 ${CX},${CY + R} A${R},${R} 0 1,1 ${CX},${CY - R}`;
}
const absTermX = Math.abs(terminatorX);
if (phase < 0.5) {
// Waxing right side lit
// Outer arc: top to bottom along right limb (sweep=1, clockwise)
// Terminator: bottom to top (elliptical arc)
const sweepTerminator = phase < 0.25 ? 1 : 0; // concave before quarter, convex after
return `M${CX},${CY - R} A${R},${R} 0 0,1 ${CX},${CY + R} A${absTermX},${R} 0 0,${sweepTerminator} ${CX},${CY - R}`;
} else {
// Waning left side lit
// Outer arc: top to bottom along left limb (sweep=0, counter-clockwise)
// Terminator: bottom to top
const sweepTerminator = phase > 0.75 ? 1 : 0;
return `M${CX},${CY - R} A${R},${R} 0 0,0 ${CX},${CY + R} A${absTermX},${R} 0 0,${sweepTerminator} ${CX},${CY - R}`;
}
};
const litPath = buildMoonPath();
return (
<div>
{/* Moon SVG */}
<div style={{ textAlign: 'center', marginBottom: '6px' }}>
<svg width="140" height="140" viewBox="0 0 140 140" style={{ display: 'block', margin: '0 auto' }}>
<defs>
{/* Crater texture */}
<radialGradient id="moonSurface" cx="40%" cy="35%" r="60%">
<stop offset="0%" stopColor="#e8e4d8" />
<stop offset="100%" stopColor="#c8c0ae" />
</radialGradient>
<radialGradient id="crater1" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#b0a898" />
<stop offset="100%" stopColor="#c4bca8" />
</radialGradient>
{/* Clip to circle */}
<clipPath id="moonClip">
<circle cx={CX} cy={CY} r={R} />
</clipPath>
</defs>
{/* Dark side (always full circle, dark) */}
<circle cx={CX} cy={CY} r={R} fill="#1a1a2e" stroke="#333" strokeWidth="1.5" />
{/* Lit surface with craters — clipped to lit path */}
{litPath && (
<g clipPath="url(#moonClip)">
<path d={litPath} fill="url(#moonSurface)" />
{/* Mare (dark patches) */}
<ellipse cx={CX - 12} cy={CY - 8} rx="18" ry="14" fill="#b8b0a0" opacity="0.5" clipPath="url(#moonClip)" />
<ellipse cx={CX + 15} cy={CY + 10} rx="12" ry="10" fill="#b0a898" opacity="0.4" clipPath="url(#moonClip)" />
<ellipse cx={CX - 5} cy={CY + 20} rx="14" ry="8" fill="#ada598" opacity="0.35" clipPath="url(#moonClip)" />
{/* Craters */}
<circle cx={CX + 20} cy={CY - 20} r="6" fill="url(#crater1)" opacity="0.5" />
<circle cx={CX - 25} cy={CY + 5} r="4" fill="url(#crater1)" opacity="0.4" />
<circle cx={CX + 8} cy={CY + 25} r="5" fill="url(#crater1)" opacity="0.45" />
<circle cx={CX - 10} cy={CY - 25} r="3.5" fill="url(#crater1)" opacity="0.35" />
<circle cx={CX + 25} cy={CY + 5} r="3" fill="url(#crater1)" opacity="0.3" />
</g>
)}
{/* Subtle glow */}
<circle cx={CX} cy={CY} r={R + 3} fill="none" stroke="rgba(200,200,180,0.1)" strokeWidth="4" />
</svg>
</div>
{/* Phase info */}
<div style={{ textAlign: 'center', marginBottom: '8px' }}>
<div style={{ fontSize: '14px', fontWeight: '700', color: 'var(--text-primary)' }}>{phaseName}</div>
<div style={{ fontSize: '12px', color: 'var(--accent-amber)', fontFamily: 'Orbitron, monospace', marginTop: '2px' }}>
{illumination}% illuminated
</div>
</div>
{/* Next phases */}
<div style={{
display: 'flex', gap: '8px', justifyContent: 'center',
fontSize: '10px', fontFamily: 'JetBrains Mono, monospace',
}}>
<div style={{
background: 'var(--bg-tertiary)', borderRadius: '4px', padding: '4px 8px', textAlign: 'center',
}}>
<div style={{ color: 'var(--text-muted)' }}>🌑 New</div>
<div style={{ color: 'var(--text-secondary)', fontWeight: '600' }}>{nextNew}</div>
</div>
<div style={{
background: 'var(--bg-tertiary)', borderRadius: '4px', padding: '4px 8px', textAlign: 'center',
}}>
<div style={{ color: 'var(--text-muted)' }}>🌕 Full</div>
<div style={{ color: 'var(--text-secondary)', fontWeight: '600' }}>{nextFull}</div>
</div>
</div>
</div>
);
};
return (
<div className="panel" style={{ padding: '8px' }}>
{/* Header with cycle button */}
@ -254,8 +410,8 @@ export const SolarPanel = ({ solarIndices }) => {
alignItems: 'center',
marginBottom: '6px'
}}>
<span style={{ fontSize: '12px', color: 'var(--accent-amber)', fontWeight: '700' }}>
{MODE_LABELS[mode]}
<span style={{ fontSize: '12px', color: mode === 'lunar' ? 'var(--accent-purple)' : 'var(--accent-amber)', fontWeight: '700' }}>
{mode === 'lunar' ? '🌙' : '☀'} {MODE_LABELS[mode]}
</span>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
{mode === 'image' && (
@ -413,6 +569,9 @@ export const SolarPanel = ({ solarIndices }) => {
) : mode === 'xray' ? (
/* X-Ray Flux Chart View */
renderXrayChart()
) : mode === 'lunar' ? (
/* Lunar Phase View */
renderLunar()
) : (
/* Solar Image View */
<div style={{ textAlign: 'center' }}>

@ -1,6 +1,6 @@
/**
* useLocalWeather Hook
* Fetches weather data from Open-Meteo API
* Fetches detailed weather data from Open-Meteo API (free, no API key)
*/
import { useState, useEffect } from 'react';
@ -15,9 +15,13 @@ const WEATHER_CODES = {
51: { desc: 'Light drizzle', icon: '🌧️' },
53: { desc: 'Moderate drizzle', icon: '🌧️' },
55: { desc: 'Dense drizzle', icon: '🌧️' },
56: { desc: 'Light freezing drizzle', icon: '🌧️' },
57: { desc: 'Dense freezing drizzle', icon: '🌧️' },
61: { desc: 'Slight rain', icon: '🌧️' },
63: { desc: 'Moderate rain', icon: '🌧️' },
65: { desc: 'Heavy rain', icon: '🌧️' },
66: { desc: 'Light freezing rain', icon: '🌧️' },
67: { desc: 'Heavy freezing rain', icon: '🌧️' },
71: { desc: 'Slight snow', icon: '🌨️' },
73: { desc: 'Moderate snow', icon: '🌨️' },
75: { desc: 'Heavy snow', icon: '❄️' },
@ -28,11 +32,18 @@ const WEATHER_CODES = {
85: { desc: 'Slight snow showers', icon: '🌨️' },
86: { desc: 'Heavy snow showers', icon: '❄️' },
95: { desc: 'Thunderstorm', icon: '⛈️' },
96: { desc: 'Thunderstorm with slight hail', icon: '⛈️' },
99: { desc: 'Thunderstorm with heavy hail', icon: '⛈️' }
96: { desc: 'Thunderstorm w/ slight hail', icon: '⛈️' },
99: { desc: 'Thunderstorm w/ heavy hail', icon: '⛈️' }
};
export const useLocalWeather = (location) => {
// Wind direction from degrees
function windDirection(deg) {
if (deg == null) return '';
const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
return dirs[Math.round(deg / 22.5) % 16];
}
export const useLocalWeather = (location, tempUnit = 'F') => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
@ -41,21 +52,103 @@ export const useLocalWeather = (location) => {
const fetchWeather = async () => {
try {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${location.lat}&longitude=${location.lon}&current=temperature_2m,weather_code,wind_speed_10m&temperature_unit=fahrenheit&wind_speed_unit=mph`;
const isMetric = tempUnit === 'C';
const params = [
`latitude=${location.lat}`,
`longitude=${location.lon}`,
'current=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,cloud_cover,pressure_msl,wind_speed_10m,wind_direction_10m,wind_gusts_10m,precipitation,uv_index,visibility,dew_point_2m,is_day',
'daily=temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,weather_code,sunrise,sunset,uv_index_max,wind_speed_10m_max',
'hourly=temperature_2m,precipitation_probability,weather_code',
`temperature_unit=${isMetric ? 'celsius' : 'fahrenheit'}`,
`wind_speed_unit=${isMetric ? 'kmh' : 'mph'}`,
`precipitation_unit=${isMetric ? 'mm' : 'inch'}`,
'timezone=auto',
'forecast_days=3',
'forecast_hours=24',
].join('&');
const url = `https://api.open-meteo.com/v1/forecast?${params}`;
const response = await fetch(url);
if (response.ok) {
const result = await response.json();
const code = result.current?.weather_code;
const weather = WEATHER_CODES[code] || { desc: 'Unknown', icon: '🌡️' };
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
const current = result.current || {};
const code = current.weather_code;
const weather = WEATHER_CODES[code] || { desc: 'Unknown', icon: '🌡️' };
const daily = result.daily || {};
const hourly = result.hourly || {};
setData({
temp: Math.round(result.current?.temperature_2m || 0),
description: weather.desc,
icon: weather.icon,
windSpeed: Math.round(result.current?.wind_speed_10m || 0),
weatherCode: code
});
// Build hourly forecast (next 24h in 3h intervals)
const hourlyForecast = [];
if (hourly.time && hourly.temperature_2m) {
for (let i = 0; i < Math.min(24, hourly.time.length); i += 3) {
const hCode = hourly.weather_code?.[i];
const hWeather = WEATHER_CODES[hCode] || { desc: '', icon: '🌡️' };
hourlyForecast.push({
time: new Date(hourly.time[i]).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }),
temp: Math.round(hourly.temperature_2m[i]),
precipProb: hourly.precipitation_probability?.[i] || 0,
icon: hWeather.icon,
});
}
}
// Build daily forecast
const dailyForecast = [];
if (daily.time) {
for (let i = 0; i < Math.min(3, daily.time.length); i++) {
const dCode = daily.weather_code?.[i];
const dWeather = WEATHER_CODES[dCode] || { desc: '', icon: '🌡️' };
dailyForecast.push({
date: new Date(daily.time[i] + 'T12:00:00').toLocaleDateString([], { weekday: 'short' }),
high: Math.round(daily.temperature_2m_max?.[i] || 0),
low: Math.round(daily.temperature_2m_min?.[i] || 0),
precipProb: daily.precipitation_probability_max?.[i] || 0,
precipSum: daily.precipitation_sum?.[i] || 0,
icon: dWeather.icon,
desc: dWeather.desc,
windMax: Math.round(daily.wind_speed_10m_max?.[i] || 0),
uvMax: daily.uv_index_max?.[i] || 0,
});
}
}
setData({
// Current conditions
temp: Math.round(current.temperature_2m || 0),
feelsLike: Math.round(current.apparent_temperature || 0),
description: weather.desc,
icon: weather.icon,
humidity: Math.round(current.relative_humidity_2m || 0),
dewPoint: Math.round(current.dew_point_2m || 0),
pressure: current.pressure_msl ? current.pressure_msl.toFixed(1) : null,
cloudCover: current.cloud_cover || 0,
windSpeed: Math.round(current.wind_speed_10m || 0),
windDir: windDirection(current.wind_direction_10m),
windDirDeg: current.wind_direction_10m || 0,
windGusts: Math.round(current.wind_gusts_10m || 0),
precipitation: current.precipitation || 0,
uvIndex: current.uv_index || 0,
visibility: current.visibility
? isMetric
? (current.visibility / 1000).toFixed(1) // meters to km
: (current.visibility / 1609.34).toFixed(1) // meters to miles
: null,
isDay: current.is_day === 1,
weatherCode: code,
// Today's highs/lows
todayHigh: daily.temperature_2m_max?.[0] ? Math.round(daily.temperature_2m_max[0]) : null,
todayLow: daily.temperature_2m_min?.[0] ? Math.round(daily.temperature_2m_min[0]) : null,
// Forecasts
hourly: hourlyForecast,
daily: dailyForecast,
// Timezone
timezone: result.timezone || '',
// Units
tempUnit: isMetric ? 'C' : 'F',
windUnit: isMetric ? 'km/h' : 'mph',
visUnit: isMetric ? 'km' : 'mi',
});
} catch (err) {
console.error('Weather error:', err);
} finally {
@ -66,7 +159,7 @@ export const useLocalWeather = (location) => {
fetchWeather();
const interval = setInterval(fetchWeather, 15 * 60 * 1000); // 15 minutes
return () => clearInterval(interval);
}, [location?.lat, location?.lon]);
}, [location?.lat, location?.lon, tempUnit]);
return { data, loading };
};

Loading…
Cancel
Save

Powered by TurnKey Linux.