Skip to content

Commit fa15e98

Browse files
claudeintelarc
authored andcommitted
Real Auckland weather in the bus scenes
Open-Meteo (free, no key) every 15 minutes: cloud cover greys the sky and thickens the clouds, drizzle and rain fall on a slant, fog hazes the skyline, thunderstorms flash; the header shows the temperature. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011C2fAvFFcMRkaF7qSvRA1v
1 parent 692e5c3 commit fa15e98

6 files changed

Lines changed: 137 additions & 21 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ Britomart and 8664 to Waikowhai. Stops and route can be changed in Settings.
1919
- **A live scene per direction.** The bus drives toward the stop as its arrival
2020
counts down, and its wheels turn while it's moving. The sky follows the real
2121
time of day: sunrise, the sun's arc, sunset, then stars and a crescent moon,
22-
with lit windows and the Sky Tower's beacon. The city lane has the Auckland
23-
skyline; the Waikowhai lane has houses and pōhutukawa in flower.
22+
with lit windows and the Sky Tower's beacon. The weather is Auckland's real
23+
weather ([Open-Meteo](https://open-meteo.com)): cloud cover greys the sky, and
24+
it drizzles, rains, fogs over or flashes with thunder when Auckland does. The
25+
city lane has the skyline with Rangitoto behind; the Waikowhai lane has houses,
26+
pōhutukawa in flower and Maungakiekie's obelisk.
2427
- **The next bus in detail.** It shows the expected arrival against the
2528
timetable, on time or minutes late, and a track of the stops between the bus
2629
and you with the last one it passed. From its GPS it also shows how far away

‎app/src/main/java/nz/aryan/akllive/AppViewModel.kt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import nz.aryan.akllive.data.TrainRepo
2121
import nz.aryan.akllive.data.TrainState
2222
import nz.aryan.akllive.data.TripDetail
2323
import nz.aryan.akllive.data.Fleet
24+
import nz.aryan.akllive.data.Weather
2425
import nz.aryan.akllive.ui.Basemap
2526
import kotlin.math.atan2
2627
import kotlin.math.hypot
@@ -73,6 +74,9 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
7374
private val _linzKey = MutableStateFlow(prefs.linzKey)
7475
val linzKey: StateFlow<String> = _linzKey
7576

77+
private val _weather = MutableStateFlow<Weather?>(null)
78+
val weather: StateFlow<Weather?> = _weather
79+
7680
private val _trains = MutableStateFlow(TrainState())
7781
val trains: StateFlow<TrainState> = _trains
7882

@@ -88,6 +92,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
8892
Fleet.load(app)
8993
viewModelScope.launch { loop(30_000) { refreshBuses() } }
9094
viewModelScope.launch { loop(15_000) { refreshTrains() } }
95+
viewModelScope.launch { loop(900_000) { Weather.fetch()?.let { _weather.value = it } } }
9196
}
9297

9398
/** Poll only while the app is on screen; refresh straight away when it comes back. */
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package nz.aryan.akllive.data
2+
3+
import kotlinx.coroutines.Dispatchers
4+
import kotlinx.coroutines.withContext
5+
import org.json.JSONObject
6+
import java.net.HttpURLConnection
7+
import java.net.URL
8+
9+
/** Auckland's weather right now, from Open-Meteo (free, no key), for the bus scenes. */
10+
data class Weather(
11+
val tempC: Double,
12+
/** WMO weather code: 0 clear, 1-3 cloud, 45/48 fog, 51-67 drizzle/rain, 80-82 showers, 95+ thunder */
13+
val code: Int,
14+
val cloudCover: Int,
15+
val precipMm: Double,
16+
) {
17+
val foggy get() = code == 45 || code == 48
18+
val thunder get() = code >= 95
19+
/** 0 none, 1 drizzle, 2 rain, 3 heavy */
20+
val rain: Int get() = when {
21+
code in 51..57 -> 1
22+
code in 61..67 || code in 80..82 || thunder -> if (code == 65 || code == 67 || code == 82 || precipMm > 4) 3 else 2
23+
precipMm > 0.2 -> 1
24+
else -> 0
25+
}
26+
27+
fun describe(night: Boolean): String = when {
28+
thunder -> "⛈ Thunder"
29+
rain >= 2 && code in 80..82 -> "🌦 Showers"
30+
rain >= 2 -> "🌧 Rain"
31+
rain == 1 -> "🌦 Drizzle"
32+
foggy -> "🌫 Fog"
33+
code == 3 || cloudCover > 85 -> "☁ Overcast"
34+
code in 1..2 || cloudCover > 30 -> if (night) "☁ Partly cloudy" else "⛅ Partly cloudy"
35+
else -> if (night) "☾ Clear" else "☀ Sunny"
36+
}
37+
38+
companion object {
39+
suspend fun fetch(): Weather? = withContext(Dispatchers.IO) {
40+
val c = URL("https://api.open-meteo.com/v1/forecast?latitude=-36.87&longitude=174.76" +
41+
"&current=temperature_2m,weather_code,cloud_cover,precipitation&timezone=Pacific%2FAuckland")
42+
.openConnection() as HttpURLConnection
43+
c.connectTimeout = 15_000
44+
c.readTimeout = 20_000
45+
try {
46+
if (c.responseCode != 200) return@withContext null
47+
val cur = JSONObject(c.inputStream.bufferedReader().use { it.readText() }).obj("current")
48+
?: return@withContext null
49+
Weather(
50+
tempC = cur.optDouble("temperature_2m"),
51+
code = cur.optInt("weather_code"),
52+
cloudCover = cur.optInt("cloud_cover"),
53+
precipMm = cur.optDouble("precipitation", 0.0),
54+
)
55+
} finally {
56+
c.disconnect()
57+
}
58+
}
59+
}
60+
}

‎app/src/main/java/nz/aryan/akllive/ui/BusScene.kt‎

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.drawscope.rotate
1212
import androidx.compose.ui.graphics.lerp
1313
import androidx.compose.ui.text.TextLayoutResult
1414
import androidx.compose.ui.text.drawText
15+
import nz.aryan.akllive.data.Weather
1516
import kotlin.math.PI
1617
import kotlin.math.cos
1718
import kotlin.math.sin
@@ -28,6 +29,7 @@ class Scenery(val city: Boolean, seed: Int) {
2829
val trees = ArrayList<Tree>()
2930
val stars: List<Triple<Float, Float, Float>>
3031
val clouds: List<FloatArray> // offset, y, scale, speed
32+
val drops: List<FloatArray> // x, phase, speed
3133

3234
init {
3335
val r = Random(seed * 7919 + 17)
@@ -63,6 +65,7 @@ class Scenery(val city: Boolean, seed: Int) {
6365
}
6466
}
6567
}
68+
drops = List(120) { floatArrayOf(r.nextFloat(), r.nextFloat(), r.nextFloat()) }
6669
}
6770
}
6871

@@ -108,13 +111,21 @@ private val Lit = Color(0xFFFFD98A)
108111
*/
109112
fun DrawScope.drawBusScene(
110113
s: Scenery, hour: Float, t: Float, progress: Float?, moving: Boolean,
111-
cancelled: Boolean, dest: TextLayoutResult?, dp: Float, look: BusLook = BusLook(),
114+
cancelled: Boolean, dest: TextLayoutResult?, dp: Float, look: BusLook = BusLook(), wx: Weather? = null,
112115
) {
113116
val w = size.width
114117
val h = size.height
115118
val ground = h * 0.74f
116-
val sky = skyAt(hour)
117119
val night = isNight(hour)
120+
// the real weather: cloud cover greys the sky, rain darkens it further
121+
val cover = (wx?.cloudCover ?: 30) / 100f
122+
val rain = wx?.rain ?: 0
123+
val gloom = (((cover - 0.5f) / 0.5f).coerceIn(0f, 1f) * 0.5f + rain * 0.08f).coerceAtMost(0.7f)
124+
val grey = if (night) Color(0xFF141922) else Color(0xFF8C95A5)
125+
val clear = skyAt(hour)
126+
val sky = Sky(lerp(clear.top, grey, gloom), lerp(clear.bottom, lerp(grey, Color.White, 0.25f), gloom),
127+
lerp(clear.hills, grey, gloom * 0.5f), lerp(clear.near, grey, gloom * 0.4f))
128+
val sunShow = (1f - gloom * 1.7f).coerceIn(0f, 1f)
118129

119130
// sky
120131
drawRect(Brush.verticalGradient(listOf(sky.top, sky.bottom), 0f, ground), size = Size(w, ground))
@@ -126,31 +137,37 @@ fun DrawScope.drawBusScene(
126137
val sy = ground - sin(p * PI).toFloat() * ground * 0.78f
127138
val low = 1f - sin(p * PI).toFloat()
128139
val sun = lerp(Color(0xFFFFF4D6), Color(0xFFFFB066), low)
129-
drawCircle(Brush.radialGradient(listOf(sun.copy(alpha = 0.55f), Color.Transparent),
140+
drawCircle(Brush.radialGradient(listOf(sun.copy(alpha = 0.55f * sunShow), Color.Transparent),
130141
Offset(sx, sy), 46 * dp), 46 * dp, Offset(sx, sy))
131-
drawCircle(sun, 13 * dp, Offset(sx, sy))
142+
drawCircle(sun.copy(alpha = sunShow), 13 * dp, Offset(sx, sy))
132143
}
133-
if (night) {
144+
if (night && sunShow > 0f) {
134145
s.stars.forEachIndexed { i, (x, y, ph) ->
135-
val a = 0.45f + 0.45f * sin(t * (0.8f + (i % 5) * 0.3f) + ph)
146+
val a = (0.45f + 0.45f * sin(t * (0.8f + (i % 5) * 0.3f) + ph)) * (1f - cover).coerceIn(0f, 1f)
136147
drawCircle(Color.White.copy(alpha = a), (if (i % 7 == 0) 1.4f else 0.9f) * dp,
137148
Offset(x * w, y * ground))
138149
}
139150
val mc = Offset(w * 0.82f, h * 0.2f)
140-
drawCircle(Brush.radialGradient(listOf(Color(0x55FFF3D0), Color.Transparent), mc, 30 * dp), 30 * dp, mc)
141-
drawCircle(Color(0xFFFBF3DA), 10 * dp, mc)
151+
drawCircle(Brush.radialGradient(listOf(Color(0x55FFF3D0).copy(alpha = 0.33f * sunShow), Color.Transparent),
152+
mc, 30 * dp), 30 * dp, mc)
153+
drawCircle(Color(0xFFFBF3DA).copy(alpha = sunShow), 10 * dp, mc)
142154
drawCircle(sky.top, 9 * dp, mc + Offset(4.5f * dp, -2.5f * dp)) // crescent
143155
}
144156

145-
// clouds drift by (daytime and dusk)
146-
if (!night) {
147-
val alpha = if (hour < 7.5f || hour > 18f) 0.7f else 0.92f
148-
for (c in s.clouds) {
157+
// clouds drift by: as many as the real cover, grey when it's raining, dark at night
158+
val clouds = if (wx == null) (if (night) 0 else 5) else Math.round(cover * 5.4f).coerceIn(0, 5)
159+
val cloudCol = when {
160+
night -> Color(0xFF2A3246).copy(alpha = 0.85f)
161+
rain > 0 || gloom > 0.3f -> lerp(Color.White, Color(0xFF8E98A8), 0.35f + gloom * 0.5f).copy(alpha = 0.95f)
162+
else -> Color.White.copy(alpha = if (hour < 7.5f || hour > 18f) 0.7f else 0.92f)
163+
}
164+
run {
165+
for (c in s.clouds.take(clouds)) {
149166
val span = w + 160 * dp
150167
val cx = ((c[0] * span + t * c[3] * dp) % span) - 80 * dp
151168
val cy = c[1] * ground
152-
val k = c[2] * dp
153-
val col = Color.White.copy(alpha = alpha)
169+
val k = c[2] * dp * (1f + gloom * 0.6f)
170+
val col = cloudCol
154171
drawCircle(col, 11 * k, Offset(cx, cy + 3 * k))
155172
drawCircle(col, 15 * k, Offset(cx + 14 * k, cy - 2 * k))
156173
drawCircle(col, 11 * k, Offset(cx + 30 * k, cy + 3 * k))
@@ -176,6 +193,12 @@ fun DrawScope.drawBusScene(
176193

177194
if (s.city) drawCity(s, sky, night, t, w, h, ground, dp) else drawSuburb(s, sky, night, w, h, ground, dp)
178195

196+
if (wx?.foggy == true) {
197+
drawRect(Brush.verticalGradient(listOf(Color.White.copy(alpha = if (night) 0.08f else 0.2f),
198+
Color.White.copy(alpha = if (night) 0.22f else 0.5f)), 0f, ground),
199+
size = Size(w, ground))
200+
}
201+
179202
// road
180203
val road = if (night) Color(0xFF22262F) else Color(0xFF3B414D)
181204
drawRect(Color(0xFF8A919C).copy(alpha = if (night) 0.5f else 1f), Offset(0f, ground), Size(w, 3 * dp))
@@ -202,6 +225,23 @@ fun DrawScope.drawBusScene(
202225
val bx = x0 + (x1 - x0) * progress.coerceIn(0f, 1f)
203226
drawBus(bx, ground + 2 * dp + (h - ground) * 0.12f, bl, bh, t, night, moving, cancelled, dest, dp, look)
204227
}
228+
229+
// rain, falling on a slant
230+
if (rain > 0) {
231+
val n = intArrayOf(0, 40, 80, 120)[rain]
232+
val len = (if (rain == 1) 6f else 11f) * dp
233+
val col = Color(0xFFD6E6FF).copy(alpha = if (rain == 1) 0.35f else 0.5f)
234+
for (i in 0 until n) {
235+
val d = s.drops[i]
236+
val y = ((d[1] + t * (1.1f + d[2] * 0.8f)) % 1f) * (h + len) - len
237+
val x = d[0] * (w + 30 * dp) - y * 0.2f
238+
drawLine(col, Offset(x, y), Offset(x - len * 0.2f, y + len), strokeWidth = 1.1f * dp, cap = StrokeCap.Round)
239+
}
240+
}
241+
if (wx?.thunder == true) {
242+
val ph = t % 9f
243+
if (ph < 0.1f || ph in 0.22f..0.3f) drawRect(Color.White.copy(alpha = 0.35f))
244+
}
205245
}
206246

207247
/** Rangitoto's low, even shield, with its summit bump, as seen from the city. */

‎app/src/main/java/nz/aryan/akllive/ui/BusScreen.kt‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import nz.aryan.akllive.data.Nz
8181
import nz.aryan.akllive.data.RouteData
8282
import nz.aryan.akllive.data.StopBoard
8383
import nz.aryan.akllive.data.Vehicle
84+
import nz.aryan.akllive.data.Weather
8485
import nz.aryan.akllive.data.occupancyText
8586
import kotlin.math.asin
8687
import kotlin.math.cos
@@ -127,6 +128,7 @@ fun BusScreen(vm: AppViewModel, modifier: Modifier) {
127128
val pulling by vm.pulling.collectAsStateWithLifecycle()
128129
val basemap by vm.basemap.collectAsStateWithLifecycle()
129130
val linzKey by vm.linzKey.collectAsStateWithLifecycle()
131+
val weather by vm.weather.collectAsStateWithLifecycle()
130132
val now by rememberNow()
131133
val frameT = rememberFrameTime()
132134
var mapOpen by rememberSaveable { mutableStateOf(false) }
@@ -143,8 +145,8 @@ fun BusScreen(vm: AppViewModel, modifier: Modifier) {
143145
contentPadding = PaddingValues(start = 14.dp, end = 14.dp, top = 12.dp, bottom = 20.dp),
144146
verticalArrangement = Arrangement.spacedBy(14.dp),
145147
) {
146-
item { BusHeader(vm.prefs.place, vm.prefs.route, now, boards, error) { vm.pullRefresh() } }
147-
itemsIndexed(boards, key = { _, b -> b.code }) { i, b -> BusCard(b, now, frameT, i) }
148+
item { BusHeader(vm.prefs.place, vm.prefs.route, now, boards, error, weather) { vm.pullRefresh() } }
149+
itemsIndexed(boards, key = { _, b -> b.code }) { i, b -> BusCard(b, now, frameT, i, weather) }
148150
item {
149151
RouteMapCard(boards, now, vm.prefs.place, basemap, linzKey, frameT) { mapOpen = true }
150152
}
@@ -160,7 +162,7 @@ fun BusScreen(vm: AppViewModel, modifier: Modifier) {
160162

161163
@Composable
162164
private fun BusHeader(place: String, route: String, now: Long, boards: List<StopBoard>, error: String?,
163-
onRefresh: () -> Unit) {
165+
weather: Weather?, onRefresh: () -> Unit) {
164166
Column {
165167
Row(verticalAlignment = Alignment.CenterVertically) {
166168
Column(Modifier.weight(1f)) {
@@ -171,6 +173,10 @@ private fun BusHeader(place: String, route: String, now: Long, boards: List<Stop
171173
}
172174
Column(horizontalAlignment = Alignment.End) {
173175
Text(Nz.time(now), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
176+
weather?.let {
177+
Text("${Math.round(it.tempC)}° · ${it.describe(isNight(Nz.hour()))}",
178+
style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
179+
}
174180
LiveBadge(boards.maxOfOrNull { it.updated } ?: 0L, now)
175181
}
176182
IconButton(onClick = onRefresh) { Icon(Icons.Filled.Refresh, "Refresh") }
@@ -227,7 +233,7 @@ fun LiveBadge(updated: Long, now: Long) {
227233
}
228234

229235
@Composable
230-
private fun BusCard(b: StopBoard, now: Long, frameT: State<Float>, index: Int) {
236+
private fun BusCard(b: StopBoard, now: Long, frameT: State<Float>, index: Int, weather: Weather?) {
231237
val next = b.departures.firstOrNull()
232238
val city = b.headsign.contains("Britomart", true) || b.headsign.contains("City", true)
233239
val scenery = remember(city, index) { Scenery(city, index) }
@@ -252,7 +258,8 @@ private fun BusCard(b: StopBoard, now: Long, frameT: State<Float>, index: Int) {
252258
drawBusScene(scenery, Nz.hour(), frameT.value,
253259
if (next == null || eta == null || eta > 1500) null else progress.value,
254260
moving = next?.live == true && (eta ?: 0) > 30,
255-
cancelled = next?.cancelled == true, dest = dest, dp = density, look = look)
261+
cancelled = next?.cancelled == true, dest = dest, dp = density, look = look,
262+
wx = weather)
256263
}
257264
// legibility veil over the top of the sky
258265
Box(Modifier.fillMaxWidth().height(90.dp).background(

‎app/src/main/java/nz/aryan/akllive/ui/SettingsScreen.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ fun SettingsScreen(vm: AppViewModel, modifier: Modifier, keepOn: Boolean, setKee
9595
Text("Live data from the Auckland Transport developer API. Not affiliated with Auckland Transport. " +
9696
"The train map follows AT's post-CRL network map; the bus art is inspired by MSMGreen/at-departure-board. " +
9797
"Maps by MapLibre, with imagery from Esri or LINZ and streets from OpenFreeMap / OpenStreetMap. " +
98+
"Weather by Open-Meteo.com (CC BY 4.0). " +
9899
"Bus models come from a community fleet list and may be incomplete.",
99100
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
100101
}

0 commit comments

Comments
 (0)