From 072091ac798b41e2fc00d502181baffb179a1637 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 22:34:23 -0500 Subject: [PATCH 1/4] weather --- src/App.jsx | 118 ++++++++++++++++++++++++++++++++++- src/hooks/useLocalWeather.js | 116 +++++++++++++++++++++++++++++----- 2 files changed, 217 insertions(+), 17 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index bfa70f0..485d7b9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -599,7 +599,7 @@ const App = () => { {/* LEFT SIDEBAR */}
- {/* DE Location */} + {/* DE Location + Weather */}
📍 DE - YOUR LOCATION
@@ -612,6 +612,122 @@ const App = () => { {deSunTimes.sunset}
+ + {/* Local Weather */} + {localWeather.data && ( +
+ {/* Current conditions hero */} +
+ {localWeather.data.icon} +
+
+ + {localWeather.data.temp}°F + + {localWeather.data.todayHigh != null && ( + + ▲{localWeather.data.todayHigh}° + {' '} + ▼{localWeather.data.todayLow}° + + )} +
+
{localWeather.data.description}
+ {localWeather.data.feelsLike !== localWeather.data.temp && ( +
Feels like {localWeather.data.feelsLike}°F
+ )} +
+
+ + {/* Detail grid */} +
+
+ 💨 Wind + {localWeather.data.windDir} {localWeather.data.windSpeed} mph +
+
+ 💧 Humidity + {localWeather.data.humidity}% +
+ {localWeather.data.windGusts > localWeather.data.windSpeed + 5 && ( +
+ 🌬️ Gusts + {localWeather.data.windGusts} mph +
+ )} +
+ 🌡️ Dew Pt + {localWeather.data.dewPoint}°F +
+ {localWeather.data.pressure && ( +
+ 🔵 Pressure + {localWeather.data.pressure} hPa +
+ )} +
+ ☁️ Clouds + {localWeather.data.cloudCover}% +
+ {localWeather.data.visibility && ( +
+ 👁️ Vis + {localWeather.data.visibility} mi +
+ )} + {localWeather.data.uvIndex > 0 && ( +
+ ☀️ UV + = 8 ? '#ef4444' : localWeather.data.uvIndex >= 6 ? '#f97316' : localWeather.data.uvIndex >= 3 ? '#eab308' : 'var(--text-secondary)' }}> + {localWeather.data.uvIndex.toFixed(1)} + +
+ )} +
+ + {/* 3-Day Forecast */} + {localWeather.data.daily?.length > 0 && ( +
+
FORECAST
+
+ {localWeather.data.daily.map((day, i) => ( +
+
{i === 0 ? 'Today' : day.date}
+
{day.icon}
+
+ {day.high}° + / + {day.low}° +
+ {day.precipProb > 0 && ( +
+ 💧{day.precipProb}% +
+ )} +
+ ))} +
+
+ )} +
+ )}
{/* DX Location */} diff --git a/src/hooks/useLocalWeather.js b/src/hooks/useLocalWeather.js index 9fee906..3798097 100644 --- a/src/hooks/useLocalWeather.js +++ b/src/hooks/useLocalWeather.js @@ -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,10 +32,17 @@ 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: '⛈️' } }; +// 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) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -41,21 +52,94 @@ export const useLocalWeather = (location) => { const fetchWeather = async () => { try { - const url = `https://api.open-meteo.com/v1/forecast?latitude=${location.lat}&longitude=${location.lon}¤t=temperature_2m,weather_code,wind_speed_10m&temperature_unit=fahrenheit&wind_speed_unit=mph`; + 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=fahrenheit', + 'wind_speed_unit=mph', + 'precipitation_unit=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: '🌡️' }; - - 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 - }); + 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 || {}; + + // 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 ? (current.visibility / 1609.34).toFixed(1) : null, // meters to miles + 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 || '', + }); } catch (err) { console.error('Weather error:', err); } finally { From 5f9409fb45df5a088ff60a097a3328ed6433f3e2 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 22:41:04 -0500 Subject: [PATCH 2/4] weather --- src/App.jsx | 220 ++++++++++++++++++++++++++++------------------------ 1 file changed, 120 insertions(+), 100 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 485d7b9..c7e052a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -99,6 +99,7 @@ const App = () => { const [showSettings, setShowSettings] = useState(false); const [showDXFilters, setShowDXFilters] = useState(false); const [showPSKFilters, setShowPSKFilters] = useState(false); + const [weatherExpanded, setWeatherExpanded] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); // Map layer visibility @@ -613,117 +614,136 @@ const App = () => { - {/* Local Weather */} + {/* Local Weather — compact by default, click to expand */} {localWeather.data && ( -
- {/* Current conditions hero */} -
- {localWeather.data.icon} -
-
- - {localWeather.data.temp}°F - +
+ {/* Compact summary row — always visible */} +
setWeatherExpanded(!weatherExpanded)} + style={{ + display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', + userSelect: 'none', padding: '2px 0', + }} + > + {localWeather.data.icon} + + {localWeather.data.temp}°F + + {localWeather.data.description} + + 💨{localWeather.data.windSpeed} + + +
+ + {/* Expanded details */} + {weatherExpanded && ( +
+ {/* Feels like + hi/lo */} +
+ {localWeather.data.feelsLike !== localWeather.data.temp && ( + Feels like {localWeather.data.feelsLike}°F + )} {localWeather.data.todayHigh != null && ( - + ▲{localWeather.data.todayHigh}° {' '} ▼{localWeather.data.todayLow}° )}
-
{localWeather.data.description}
- {localWeather.data.feelsLike !== localWeather.data.temp && ( -
Feels like {localWeather.data.feelsLike}°F
- )} -
-
- - {/* Detail grid */} -
-
- 💨 Wind - {localWeather.data.windDir} {localWeather.data.windSpeed} mph -
-
- 💧 Humidity - {localWeather.data.humidity}% -
- {localWeather.data.windGusts > localWeather.data.windSpeed + 5 && ( -
- 🌬️ Gusts - {localWeather.data.windGusts} mph -
- )} -
- 🌡️ Dew Pt - {localWeather.data.dewPoint}°F -
- {localWeather.data.pressure && ( -
- 🔵 Pressure - {localWeather.data.pressure} hPa -
- )} -
- ☁️ Clouds - {localWeather.data.cloudCover}% -
- {localWeather.data.visibility && ( -
- 👁️ Vis - {localWeather.data.visibility} mi -
- )} - {localWeather.data.uvIndex > 0 && ( -
- ☀️ UV - = 8 ? '#ef4444' : localWeather.data.uvIndex >= 6 ? '#f97316' : localWeather.data.uvIndex >= 3 ? '#eab308' : 'var(--text-secondary)' }}> - {localWeather.data.uvIndex.toFixed(1)} - + + {/* Detail grid */} +
+
+ 💨 Wind + {localWeather.data.windDir} {localWeather.data.windSpeed} mph +
+
+ 💧 Humidity + {localWeather.data.humidity}% +
+ {localWeather.data.windGusts > localWeather.data.windSpeed + 5 && ( +
+ 🌬️ Gusts + {localWeather.data.windGusts} mph +
+ )} +
+ 🌡️ Dew Pt + {localWeather.data.dewPoint}°F +
+ {localWeather.data.pressure && ( +
+ 🔵 Pressure + {localWeather.data.pressure} hPa +
+ )} +
+ ☁️ Clouds + {localWeather.data.cloudCover}% +
+ {localWeather.data.visibility && ( +
+ 👁️ Vis + {localWeather.data.visibility} mi +
+ )} + {localWeather.data.uvIndex > 0 && ( +
+ ☀️ UV + = 8 ? '#ef4444' : localWeather.data.uvIndex >= 6 ? '#f97316' : localWeather.data.uvIndex >= 3 ? '#eab308' : 'var(--text-secondary)' }}> + {localWeather.data.uvIndex.toFixed(1)} + +
+ )}
- )} -
- - {/* 3-Day Forecast */} - {localWeather.data.daily?.length > 0 && ( -
-
FORECAST
-
- {localWeather.data.daily.map((day, i) => ( -
-
{i === 0 ? 'Today' : day.date}
-
{day.icon}
-
- {day.high}° - / - {day.low}° -
- {day.precipProb > 0 && ( -
- 💧{day.precipProb}% + + {/* 3-Day Forecast */} + {localWeather.data.daily?.length > 0 && ( +
+
FORECAST
+
+ {localWeather.data.daily.map((day, i) => ( +
+
{i === 0 ? 'Today' : day.date}
+
{day.icon}
+
+ {day.high}° + / + {day.low}° +
+ {day.precipProb > 0 && ( +
+ 💧{day.precipProb}% +
+ )}
- )} + ))}
- ))} -
+
+ )}
)}
From 0ef938f87e4911a1a2c2e6724dd1a6a5180a93e7 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 22:49:45 -0500 Subject: [PATCH 3/4] lunar panel --- src/components/SolarPanel.jsx | 175 ++++++++++++++++++++++++++++++++-- 1 file changed, 167 insertions(+), 8 deletions(-) diff --git a/src/components/SolarPanel.jsx b/src/components/SolarPanel.jsx index cb98a78..6667631 100644 --- a/src/components/SolarPanel.jsx +++ b/src/components/SolarPanel.jsx @@ -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 ( +
+ {/* Moon SVG */} +
+ + + {/* Crater texture */} + + + + + + + + + {/* Clip to circle */} + + + + + + {/* Dark side (always full circle, dark) */} + + + {/* Lit surface with craters — clipped to lit path */} + {litPath && ( + + + {/* Mare (dark patches) */} + + + + {/* Craters */} + + + + + + + )} + + {/* Subtle glow */} + + +
+ + {/* Phase info */} +
+
{phaseName}
+
+ {illumination}% illuminated +
+
+ + {/* Next phases */} +
+
+
🌑 New
+
{nextNew}
+
+
+
🌕 Full
+
{nextFull}
+
+
+
+ ); + }; + return (
{/* Header with cycle button */} @@ -254,8 +410,8 @@ export const SolarPanel = ({ solarIndices }) => { alignItems: 'center', marginBottom: '6px' }}> - - ☀ {MODE_LABELS[mode]} + + {mode === 'lunar' ? '🌙' : '☀'} {MODE_LABELS[mode]}
{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 */
From 3a3f055e02c514680987d4fbbd57675350acbcb9 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 23:00:17 -0500 Subject: [PATCH 4/4] celcius --- src/App.jsx | 121 ++++++++++++++++++++++------------- src/hooks/useLocalWeather.js | 21 ++++-- 2 files changed, 93 insertions(+), 49 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index c7e052a..e23bee4 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -100,6 +100,9 @@ const App = () => { 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 @@ -209,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(); @@ -615,29 +618,60 @@ const App = () => {
{/* Local Weather — compact by default, click to expand */} - {localWeather.data && ( + {localWeather.data && (() => { + const w = localWeather.data; + const deg = `°${w.tempUnit || tempUnit}`; + const wind = w.windUnit || 'mph'; + const vis = w.visUnit || 'mi'; + return (
{/* Compact summary row — always visible */} -
setWeatherExpanded(!weatherExpanded)} - style={{ - display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', - userSelect: 'none', padding: '2px 0', - }} - > - {localWeather.data.icon} - - {localWeather.data.temp}°F - - {localWeather.data.description} - - 💨{localWeather.data.windSpeed} - - +
+
setWeatherExpanded(!weatherExpanded)} + style={{ + display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', + userSelect: 'none', flex: 1, minWidth: 0, + }} + > + {w.icon} + + {w.temp}{deg} + + {w.description} + + 💨{w.windSpeed} + + +
+ {/* F/C toggle */} +
{/* Expanded details */} @@ -645,14 +679,14 @@ const App = () => {
{/* Feels like + hi/lo */}
- {localWeather.data.feelsLike !== localWeather.data.temp && ( - Feels like {localWeather.data.feelsLike}°F + {w.feelsLike !== w.temp && ( + Feels like {w.feelsLike}{deg} )} - {localWeather.data.todayHigh != null && ( + {w.todayHigh != null && ( - ▲{localWeather.data.todayHigh}° + ▲{w.todayHigh}° {' '} - ▼{localWeather.data.todayLow}° + ▼{w.todayLow}° )}
@@ -667,50 +701,50 @@ const App = () => { }}>
💨 Wind - {localWeather.data.windDir} {localWeather.data.windSpeed} mph + {w.windDir} {w.windSpeed} {wind}
💧 Humidity - {localWeather.data.humidity}% + {w.humidity}%
- {localWeather.data.windGusts > localWeather.data.windSpeed + 5 && ( + {w.windGusts > w.windSpeed + 5 && (
🌬️ Gusts - {localWeather.data.windGusts} mph + {w.windGusts} {wind}
)}
🌡️ Dew Pt - {localWeather.data.dewPoint}°F + {w.dewPoint}{deg}
- {localWeather.data.pressure && ( + {w.pressure && (
🔵 Pressure - {localWeather.data.pressure} hPa + {w.pressure} hPa
)}
☁️ Clouds - {localWeather.data.cloudCover}% + {w.cloudCover}%
- {localWeather.data.visibility && ( + {w.visibility && (
👁️ Vis - {localWeather.data.visibility} mi + {w.visibility} {vis}
)} - {localWeather.data.uvIndex > 0 && ( + {w.uvIndex > 0 && (
☀️ UV - = 8 ? '#ef4444' : localWeather.data.uvIndex >= 6 ? '#f97316' : localWeather.data.uvIndex >= 3 ? '#eab308' : 'var(--text-secondary)' }}> - {localWeather.data.uvIndex.toFixed(1)} + = 8 ? '#ef4444' : w.uvIndex >= 6 ? '#f97316' : w.uvIndex >= 3 ? '#eab308' : 'var(--text-secondary)' }}> + {w.uvIndex.toFixed(1)}
)}
{/* 3-Day Forecast */} - {localWeather.data.daily?.length > 0 && ( + {w.daily?.length > 0 && (
{ }}>
FORECAST
- {localWeather.data.daily.map((day, i) => ( + {w.daily.map((day, i) => (
{
)}
- )} + ); + })()}
{/* DX Location */} diff --git a/src/hooks/useLocalWeather.js b/src/hooks/useLocalWeather.js index 3798097..d0afd86 100644 --- a/src/hooks/useLocalWeather.js +++ b/src/hooks/useLocalWeather.js @@ -43,7 +43,7 @@ function windDirection(deg) { return dirs[Math.round(deg / 22.5) % 16]; } -export const useLocalWeather = (location) => { +export const useLocalWeather = (location, tempUnit = 'F') => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -52,15 +52,16 @@ export const useLocalWeather = (location) => { const fetchWeather = async () => { try { + 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=fahrenheit', - 'wind_speed_unit=mph', - 'precipitation_unit=inch', + `temperature_unit=${isMetric ? 'celsius' : 'fahrenheit'}`, + `wind_speed_unit=${isMetric ? 'kmh' : 'mph'}`, + `precipitation_unit=${isMetric ? 'mm' : 'inch'}`, 'timezone=auto', 'forecast_days=3', 'forecast_hours=24', @@ -128,7 +129,11 @@ export const useLocalWeather = (location) => { windGusts: Math.round(current.wind_gusts_10m || 0), precipitation: current.precipitation || 0, uvIndex: current.uv_index || 0, - visibility: current.visibility ? (current.visibility / 1609.34).toFixed(1) : null, // meters to miles + 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 @@ -139,6 +144,10 @@ export const useLocalWeather = (location) => { 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); @@ -150,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 }; };