Skip to content

Commit 966d0c3

Browse files
authored
Merge pull request #22 from badgerloop-software/copilot/add-weather-overlay-fsgp-tab-again
feat(strategy): FSGP/ASC tabs with Meteoblue weather overlay map
2 parents 1210ec3 + cc8f33e commit 966d0c3

5 files changed

Lines changed: 1277 additions & 136 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,8 @@ EXPO_PUBLIC_INFLUX_BUCKET=sc2-telemetry
3434

3535
# ── Mapbox (optional — for map features) ─────────────────────────────────────
3636
MAPBOX_ACCESS_TOKEN=pk.your_mapbox_token_here
37+
38+
# ── Meteoblue (weather overlay for Strategy map) ──────────────────────────────
39+
# Sign up at https://www.meteoblue.com/en/weather-api to get an API key.
40+
# Required for weather tile overlays (clouds, precipitation, wind, solar) on the Strategy screen.
41+
METEOBLUE_API_KEY=your_meteoblue_api_key_here

app.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ module.exports = ({ config }) => {
2323
// Preserve any existing extra values
2424
...(config.extra || {}),
2525
MAPBOX_ACCESS_TOKEN: process.env.MAPBOX_ACCESS_TOKEN || null,
26+
METEOBLUE_API_KEY: process.env.METEOBLUE_API_KEY || null,
2627
},
2728
};
2829
};
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import React, { useState, useEffect } from 'react';
2+
import { View, Text, StyleSheet, ScrollView, ActivityIndicator } from 'react-native';
3+
import { Ionicons } from '@expo/vector-icons';
4+
import { useTheme } from '../theme/ThemeProvider';
5+
6+
// Get weather icon based on WMO weather code
7+
const getWeatherIcon = (weatherCode) => {
8+
const iconMap = {
9+
0: 'sunny', // Clear sky
10+
1: 'partly-sunny', // Mainly clear
11+
2: 'partly-sunny', // Partly cloudy
12+
3: 'cloudy', // Overcast
13+
45: 'cloudy', // Fog
14+
48: 'cloudy', // Fog
15+
51: 'rainy', // Drizzle
16+
53: 'rainy', // Drizzle
17+
55: 'rainy', // Dense drizzle
18+
61: 'rainy', // Rain
19+
63: 'rainy', // Rain
20+
65: 'rainy', // Heavy rain
21+
71: 'snow', // Snow
22+
73: 'snow', // Snow
23+
75: 'snow', // Heavy snow
24+
95: 'thunderstorm', // Thunderstorm
25+
};
26+
return iconMap[weatherCode] || 'cloudy';
27+
};
28+
29+
const getDayName = (dateStr) => {
30+
const date = new Date(dateStr);
31+
const today = new Date();
32+
if (date.getUTCDay() === today.getDay() && date.getUTCDate() === today.getDate()) {
33+
return 'Today';
34+
}
35+
return date.toLocaleDateString('en-US', { weekday: 'short', timeZone: 'UTC' });
36+
};
37+
38+
const formatTime = (timeStr) => {
39+
const date = new Date(timeStr);
40+
let hours = date.getHours();
41+
const ampm = hours >= 12 ? 'PM' : 'AM';
42+
hours = hours % 12;
43+
hours = hours ? hours : 12; // the hour '0' should be '12'
44+
return `${hours} ${ampm}`;
45+
};
46+
47+
export default function WeatherForecastUI({ latitude, longitude, useImperialUnits = true }) {
48+
const { theme } = useTheme();
49+
const [data, setData] = useState(null);
50+
const [loading, setLoading] = useState(true);
51+
const [error, setError] = useState(null);
52+
53+
useEffect(() => {
54+
if (!latitude || !longitude) return;
55+
56+
const fetchForecast = async () => {
57+
setLoading(true);
58+
setError(null);
59+
try {
60+
const tempUnits = useImperialUnits ? 'fahrenheit' : 'celsius';
61+
// Open-Meteo free API for hourly (24h) and daily (7 days)
62+
const url = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&hourly=temperature_2m,weathercode&daily=weathercode,temperature_2m_max,temperature_2m_min&timezone=auto&temperature_unit=${tempUnits}`;
63+
const res = await fetch(url);
64+
if (!res.ok) throw new Error('Failed to fetch forecast');
65+
const json = await res.json();
66+
setData(json);
67+
} catch (err) {
68+
setError('Forecast unavailable');
69+
} finally {
70+
setLoading(false);
71+
}
72+
};
73+
74+
fetchForecast();
75+
}, [latitude, longitude, useImperialUnits]);
76+
77+
if (loading) {
78+
return (
79+
<View style={[styles.loadingContainer, { backgroundColor: theme.colors.card }]}>
80+
<ActivityIndicator color={theme.colors.primary} />
81+
</View>
82+
);
83+
}
84+
85+
if (error || !data) {
86+
return (
87+
<View style={[styles.errorContainer, { backgroundColor: theme.colors.card }]}>
88+
<Text style={[styles.errorText, { color: theme.colors.muted }]}>{error || 'Weather forecast unavailable'}</Text>
89+
</View>
90+
);
91+
}
92+
93+
// Parse Hourly (Next 24 hours)
94+
const now = new Date();
95+
const currentHourUTCStr = now.toISOString().slice(0, 13) + ':00'; // Match open-meteo basic ISO pattern to find index natively
96+
let startIndex = 0;
97+
for (let i = 0; i < data.hourly.time.length; i++) {
98+
if (new Date(data.hourly.time[i]).getTime() >= now.getTime()) {
99+
startIndex = i;
100+
break;
101+
}
102+
}
103+
// Take next 24 hours
104+
const hourlyData = data.hourly.time.slice(startIndex, startIndex + 24).map((time, i) => ({
105+
time,
106+
temp: Math.round(data.hourly.temperature_2m[startIndex + i]),
107+
weathercode: data.hourly.weathercode[startIndex + i]
108+
}));
109+
110+
// Parse Daily (7 Days)
111+
const dailyData = data.daily.time.map((time, i) => ({
112+
time,
113+
min: Math.round(data.daily.temperature_2m_min[i]),
114+
max: Math.round(data.daily.temperature_2m_max[i]),
115+
weathercode: data.daily.weathercode[i]
116+
}));
117+
118+
// Overall min/max for the week bar scaling
119+
const absoluteMin = Math.min(...dailyData.map(d => d.min));
120+
const absoluteMax = Math.max(...dailyData.map(d => d.max));
121+
const rangeTotal = absoluteMax - absoluteMin || 1;
122+
123+
const tempSym = useImperialUnits ? '°F' : '°C';
124+
125+
return (
126+
<View style={styles.container}>
127+
{/* 24-Hour Forecast Scroll */}
128+
<View style={[styles.box, { backgroundColor: theme.colors.card }]}>
129+
<View style={styles.boxHeader}>
130+
<Ionicons name="time-outline" size={14} color={theme.colors.muted} />
131+
<Text style={[styles.boxTitle, { color: theme.colors.muted }]}>24-HOUR FORECAST</Text>
132+
</View>
133+
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.hourlyScroll}>
134+
{hourlyData.map((hour, i) => (
135+
<View key={i} style={styles.hourlyItem}>
136+
<Text style={[styles.hourlyTime, { color: theme.colors.text }]}>
137+
{i === 0 ? 'Now' : formatTime(hour.time)}
138+
</Text>
139+
<Ionicons name={getWeatherIcon(hour.weathercode)} size={22} color={theme.colors.primary} style={styles.hourlyIcon} />
140+
<Text style={[styles.hourlyTemp, { color: theme.colors.text }]}>{hour.temp}°</Text>
141+
</View>
142+
))}
143+
</ScrollView>
144+
</View>
145+
146+
{/* 7-Day Forecast Box */}
147+
<View style={[styles.box, { backgroundColor: theme.colors.card }]}>
148+
<View style={styles.boxHeader}>
149+
<Ionicons name="calendar-outline" size={14} color={theme.colors.muted} />
150+
<Text style={[styles.boxTitle, { color: theme.colors.muted }]}>7-DAY FORECAST</Text>
151+
</View>
152+
<View style={styles.dailyList}>
153+
{dailyData.map((day, i) => {
154+
const leftOffset = ((day.min - absoluteMin) / rangeTotal) * 100;
155+
const barWidth = ((day.max - day.min) / rangeTotal) * 100;
156+
157+
return (
158+
<View key={i} style={styles.dailyRow}>
159+
<Text style={[styles.dailyDayName, { color: theme.colors.text }]}>{getDayName(day.time)}</Text>
160+
<View style={styles.dailyIconBox}>
161+
<Ionicons name={getWeatherIcon(day.weathercode)} size={20} color={theme.colors.primary} />
162+
</View>
163+
<Text style={[styles.dailyMin, { color: theme.colors.muted }]}>{day.min}°</Text>
164+
165+
{/* Visual Temperature Distribution Bar */}
166+
<View style={[styles.dailyBarWrapper, { backgroundColor: 'rgba(156, 163, 175, 0.2)' }]}>
167+
<View
168+
style={[
169+
styles.dailyBar,
170+
{
171+
left: `${leftOffset}%`,
172+
width: `${barWidth}%`,
173+
backgroundColor: theme.colors.primary
174+
}
175+
]}
176+
/>
177+
</View>
178+
179+
<Text style={[styles.dailyMax, { color: theme.colors.text }]}>{day.max}°</Text>
180+
</View>
181+
);
182+
})}
183+
</View>
184+
</View>
185+
</View>
186+
);
187+
}
188+
189+
const styles = StyleSheet.create({
190+
container: {
191+
paddingHorizontal: 20,
192+
marginBottom: 16,
193+
gap: 12,
194+
},
195+
loadingContainer: {
196+
height: 120,
197+
marginHorizontal: 20,
198+
marginBottom: 16,
199+
borderRadius: 14,
200+
justifyContent: 'center',
201+
alignItems: 'center',
202+
},
203+
errorContainer: {
204+
height: 80,
205+
marginHorizontal: 20,
206+
marginBottom: 16,
207+
borderRadius: 14,
208+
justifyContent: 'center',
209+
alignItems: 'center',
210+
},
211+
errorText: {
212+
fontSize: 13,
213+
},
214+
box: {
215+
borderRadius: 14,
216+
overflow: 'hidden',
217+
padding: 14,
218+
},
219+
boxHeader: {
220+
flexDirection: 'row',
221+
alignItems: 'center',
222+
marginBottom: 12,
223+
gap: 6,
224+
},
225+
boxTitle: {
226+
fontSize: 11,
227+
fontWeight: '700',
228+
letterSpacing: 0.5,
229+
},
230+
hourlyScroll: {
231+
paddingBottom: 4,
232+
},
233+
hourlyItem: {
234+
alignItems: 'center',
235+
marginRight: 22,
236+
},
237+
hourlyTime: {
238+
fontSize: 13,
239+
fontWeight: '500',
240+
marginBottom: 8,
241+
},
242+
hourlyIcon: {
243+
marginBottom: 8,
244+
},
245+
hourlyTemp: {
246+
fontSize: 15,
247+
fontWeight: '600',
248+
},
249+
dailyList: {
250+
gap: 12,
251+
},
252+
dailyRow: {
253+
flexDirection: 'row',
254+
alignItems: 'center',
255+
},
256+
dailyDayName: {
257+
width: 48,
258+
fontSize: 14,
259+
fontWeight: '600',
260+
},
261+
dailyIconBox: {
262+
width: 32,
263+
alignItems: 'center',
264+
},
265+
dailyMin: {
266+
width: 32,
267+
textAlign: 'right',
268+
fontSize: 14,
269+
fontWeight: '500',
270+
marginRight: 8,
271+
},
272+
dailyBarWrapper: {
273+
flex: 1,
274+
height: 6,
275+
borderRadius: 3,
276+
marginHorizontal: 4,
277+
overflow: 'hidden',
278+
position: 'relative',
279+
},
280+
dailyBar: {
281+
position: 'absolute',
282+
height: '100%',
283+
borderRadius: 3,
284+
},
285+
dailyMax: {
286+
width: 32,
287+
textAlign: 'right',
288+
fontSize: 14,
289+
fontWeight: '600',
290+
marginLeft: 8,
291+
},
292+
});

0 commit comments

Comments
 (0)