Wetter: stündliche Niederschlags-Timeline (WeatherKit hourlyForecast)

Säulen-Diagramm zwischen Stats-Grid und Hunde-Hinweisen:
- Heute: nächste 12h ab jetzt
- Andere Tage: 06–22h des Tages
- Säulenhöhe aus precipitationAmount in mm, Farbgradient aus Intensität
  (chance + mm). Header zeigt durchschnittliche Regenwahrscheinlichkeit
  oder 'trocken' wenn < 5%.
This commit is contained in:
rene 2026-05-30 13:51:14 +02:00
parent 70e0a238e9
commit 5c4754caff

View file

@ -54,6 +54,7 @@ struct WetterView: View {
gassiScoreBadge(metrics: metrics)
summaryCard(day: day, metrics: metrics)
statsGrid(metrics: metrics)
rainTimeline(weather: weather, day: day, isToday: safeIndex == 0)
hundeHinweise(day: day, metrics: metrics)
schnueffelCard(metrics: metrics)
}
@ -187,6 +188,99 @@ struct WetterView: View {
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
}
// MARK: - Niederschlags-Timeline
private func rainTimeline(weather: Weather, day: DayWeather, isToday: Bool) -> some View {
let hours = relevantHours(weather: weather, day: day, isToday: isToday)
let maxMm = max(hours.map { $0.precipitationAmount.converted(to: .millimeters).value }.max() ?? 0, 0.5)
let totalChance = hours.reduce(0.0) { $0 + $1.precipitationChance } / Double(max(1, hours.count))
return VStack(alignment: .leading, spacing: 10) {
HStack {
Label("Niederschlag", systemImage: "umbrella.fill")
.font(.headline)
.foregroundStyle(.blue)
Spacer()
Text(headerLabel(isToday: isToday, totalChance: totalChance))
.font(.caption)
.foregroundStyle(.secondary)
}
if hours.isEmpty {
Text("Keine stündlichen Daten verfügbar.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .bottom, spacing: 6) {
ForEach(Array(hours.enumerated()), id: \.offset) { _, hour in
rainBar(for: hour, scaleMm: maxMm)
}
}
.frame(height: 110)
}
}
}
.padding(14)
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 12))
}
private func relevantHours(weather: Weather, day: DayWeather, isToday: Bool) -> [HourWeather] {
let all = weather.hourlyForecast
if isToday {
// Ab jetzt die nächsten 12 Stunden
return Array(all.filter { $0.date >= .now }.prefix(12))
}
// Der ausgewählte Tag, 06:00 bis 22:00
let cal = Calendar.current
return all
.filter { cal.isDate($0.date, inSameDayAs: day.date) }
.filter {
let h = cal.component(.hour, from: $0.date)
return h >= 6 && h <= 22
}
}
private func rainBar(for hour: HourWeather, scaleMm: Double) -> some View {
let chance = hour.precipitationChance
let mm = hour.precipitationAmount.converted(to: .millimeters).value
// Höhe primär aus mm, mindestens 6 (auch bei 0 sichtbarer Bar-Sockel)
let height: CGFloat = max(6, CGFloat(mm / scaleMm) * 50)
let intensity: Double = min(1.0, chance + mm / 5)
let baseColor = Color.blue
return VStack(spacing: 3) {
Text("\(Int((chance * 100).rounded()))%")
.font(.caption2.monospacedDigit())
.foregroundStyle(chance >= 0.3 ? .primary : .secondary)
Rectangle()
.fill(LinearGradient(
colors: [baseColor.opacity(0.4 + 0.6 * intensity), baseColor.opacity(0.2 + 0.2 * intensity)],
startPoint: .top,
endPoint: .bottom
))
.frame(width: 22, height: height)
.clipShape(RoundedRectangle(cornerRadius: 4))
Text(hourLabel(hour.date))
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
.frame(height: 100, alignment: .bottom)
}
private func hourLabel(_ date: Date) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "de_DE")
f.dateFormat = "HH"
return "\(f.string(from: date)):00"
}
private func headerLabel(isToday: Bool, totalChance: Double) -> String {
let pct = Int((totalChance * 100).rounded())
let prefix = isToday ? "nächste 12 h" : "0622 h"
if totalChance < 0.05 { return "\(prefix) · trocken" }
return "\(prefix) · ⌀ \(pct) % Regen"
}
// MARK: - Hunde-Hinweise
private func hundeHinweise(day: DayWeather, metrics: DayMetrics) -> some View {