Outside
the sky over Mexico City, drawn from a Weather: the sun placed by Place.sun(at:) from the clock, clouds by cover, rain by the millimeter, everything drifting with the wind, and a caption that says the city is still being looked up until it is
- Run
swift run --package-path Examples Example-Data-Outside- Source
- Examples/Data/Outside/Sketch.swift
import Foundation
import Ollin
/// The sky outside, drawn from the weather over one city.
///
/// A `Weather` is a `DataFeed` that already knows the address to ask and the
/// shape of the answer, so the sketch reads `cloudCover`, `windSpeed`, and
/// `condition` the way it would read a slider. The city is looked up by name
/// when the weather starts, and the reading carries the place it turned out
/// to be.
///
/// The picture is a view of the southern sky: east on the left, west on the
/// right. The sun is not fetched at all. `Place.sun(at:)` works out where it is
/// from the place and the clock, so the sky is the right color for the hour
/// before the first reading arrives, and the clouds, the rain, and the wind
/// join once it does. Nothing is drawn that is not known: until the reading is
/// in, the caption says the sketch is still asking.
///
/// Data: current conditions from Open-Meteo (CC BY 4.0), read live and not
/// redistributed here.
@main
final class Outside: Sketch {
override var canvasSize: CanvasSize { .size(1440, 720) }
private let weather = Weather(in: "Mexico City")
/// A cloud is a few soft discs around one point, drifting with the wind.
private struct Cloud {
var x: Double
var y: Double
var size: Double
var puffs: [(dx: Double, dy: Double, r: Double)]
}
private var clouds: [Cloud] = []
private var drift = 0.0
/// Where the horizon sits, as a fraction of the height.
private let horizon = 0.78
override func setup() {
weather.start()
// The cloud shapes are laid out once, spread by two different
// irrational steps so no two land together and the two axes do not
// line up, and only how many show depends on the sky.
clouds = (0..<40).map { i in
let u = (Double(i) * 0.618034).truncatingRemainder(dividingBy: 1)
let v = (Double(i) * 0.414214).truncatingRemainder(dividingBy: 1)
return Cloud(
x: u * width,
y: 60 + v * v * height * 0.5,
size: 26 + (1 - v) * 44,
puffs: (0..<5).map { p in
let a = Double(p) * 1.257 + Double(i)
return (cos(a) * 0.9, sin(a) * 0.35 - 0.1, 0.55 + 0.45 * (0.5 + 0.5 * sin(a * 2.3)))
}
)
}
}
override func draw() {
let reading = weather.reading
let place = reading?.place
let sun = place?.sun(at: Date())
let elevation = sun?.elevation ?? 30
drawSky(elevation: elevation, clouds: reading?.cloudCover ?? 0.5)
if let sun, let place { drawSun(sun, at: place) }
if let reading { drawWeather(reading, elevation: elevation) }
drawGround(elevation: elevation)
drawCaption(reading)
}
// MARK: The sky
/// The sky's color runs with the sun's height: deep blue at night, a warm
/// band at the horizon around sunrise and sunset, blue by day. Cloud
/// flattens all of it toward gray.
private func drawSky(elevation: Double, clouds: Double) {
let night = Color(hex: 0x0A1230)
let dusk = Color(hex: 0xE8874A)
let dayTop = Color(hex: 0x3E7CC9)
let dayHorizon = Color(hex: 0xBBD6EE)
let gray = Color(hex: 0x8C949E)
// How far into day we are, and how close to the horizon the sun sits.
let day = map(elevation, -12, 10, 0, 1, clamped: true)
let glow = 1 - min(1, abs(elevation - 1) / 10)
noStroke()
let bands = 120
for i in 0..<bands {
let t = Double(i) / Double(bands - 1) // 0 at the top
let top = Color.mix(night, dayTop, day)
let low = Color.mix(night, dayHorizon, day)
var color = Color.mix(top, low, t * t)
color = Color.mix(color, dusk, glow * t * t * t * 0.9)
color = Color.mix(color, Color.mix(gray, night, 1 - day), clouds * 0.7 * (0.4 + 0.6 * t))
fill(color)
let y = t * height * horizon
drawRect(0, y, width, height * horizon / Double(bands) + 1)
}
}
/// East on the left, west on the right, the horizon where the ground starts.
private func skyPoint(azimuth: Double, elevation: Double) -> Vector2 {
let x = map(azimuth, 60, 300, 0, width)
let y = height * horizon - elevation / 90 * height * 0.72
return Vector2(x, y)
}
private func drawSun(_ sun: SunPosition, at place: Place) {
guard sun.elevation > -8 else { return }
let at = skyPoint(azimuth: sun.azimuth, elevation: sun.elevation)
let low = map(sun.elevation, -2, 15, 1, 0, clamped: true)
let color = Color.mix(Color(hex: 0xFFF4D6), Color(hex: 0xFF9A4A), low)
noStroke()
for ring in stride(from: 12, through: 1, by: -1) {
fill(color.withAlpha(0.018 * Double(13 - ring)))
drawCircle(center: at, radius: 40 + Double(ring) * 11)
}
fill(color)
drawCircle(center: at, radius: 36)
}
// MARK: The weather
private func drawWeather(_ reading: Weather.Reading, elevation: Double) {
let day = map(elevation, -12, 10, 0, 1, clamped: true)
// Wind. The direction is where it blows from, and east is on the
// left, so a west wind carries the clouds leftward across this view.
let toward = (reading.windDirection + 180) * .pi / 180
drift += -sin(toward) * reading.windSpeed * 0.06
let sway = sin(time * 0.7) * reading.windSpeed * 0.4
// Clouds: how many show, and how gray they are, follow the cover.
let showing = Int(Double(clouds.count) * reading.cloudCover)
let cloudColor = Color.mix(Color(hex: 0x2A3350), Color(hex: 0xF4F6FA), day)
noStroke()
for cloud in clouds.prefix(showing) {
let x = (cloud.x + drift + sway * (cloud.size / 110)).truncatingRemainder(dividingBy: width + 240)
let cx = x < -120 ? x + width + 240 : x
for puff in cloud.puffs {
let r = cloud.size * puff.r
fill(cloudColor.withAlpha(0.06))
drawCircle(cx + puff.dx * cloud.size + 6, cloud.y + puff.dy * cloud.size + 6, r * 1.25)
fill(cloudColor.withAlpha(0.85))
drawCircle(cx + puff.dx * cloud.size, cloud.y + puff.dy * cloud.size, r)
}
}
// Fog is a veil over everything below the clouds.
if reading.condition == .fog {
fill(Color(white: 0.82).withAlpha(0.55 * (0.4 + 0.6 * day)))
drawRect(0, height * 0.3, width, height * (horizon - 0.3))
}
// Rain and snow fall from the cloud line, leaning with the wind.
guard reading.condition.isPrecipitating || reading.precipitation > 0 else { return }
let lean = -sin(toward) * min(1, reading.windSpeed / 12) * 0.35
if reading.condition.isSnowing {
let flakes = Int(map(reading.snowfall, 0, 20, 60, 400, clamped: true))
fill(Color(white: 1).withAlpha(0.8))
for i in 0..<flakes {
let seed = Double(i) * 0.618034
let phase = (time * (0.05 + (seed - floor(seed)) * 0.06) + seed).truncatingRemainder(dividingBy: 1)
let x = ((seed * 7.3) - floor(seed * 7.3)) * width + sin(time + Double(i)) * 14 + phase * lean * 300
let y = phase * height * horizon
drawCircle(x, y, 2 + (seed - floor(seed)) * 2.5)
}
} else {
let streaks = Int(map(reading.precipitation, 0, 6, 80, 700, clamped: true))
stroke(Color(white: 0.9).withAlpha(0.35))
strokeWeight(1.2)
let length = 22 + reading.windSpeed * 1.5
for i in 0..<streaks {
let seed = Double(i) * 0.618034
let phase = (time * (0.8 + (seed - floor(seed)) * 0.5) + seed).truncatingRemainder(dividingBy: 1)
let x = ((seed * 7.3) - floor(seed * 7.3)) * width + phase * lean * 400
let y = phase * height * horizon
drawLine(x, y, x + lean * length, y + length)
}
}
}
// MARK: The ground and the caption
private func drawGround(elevation: Double) {
let day = map(elevation, -12, 10, 0, 1, clamped: true)
noStroke()
fill(Color.mix(Color(hex: 0x070A16), Color(hex: 0x3B4A3A), day))
drawRect(0, height * horizon, width, height * (1 - horizon))
// A low line of roofs, so the horizon reads as a city and not an edge.
fill(Color.mix(Color(hex: 0x05070F), Color(hex: 0x2A3630), day))
var x = 0.0
var i = 0
while x < width {
let w = 30 + Double((i * 37) % 70)
let h = 10 + Double((i * 53) % 46)
drawRect(x, height * horizon - h, w, h)
x += w + 4
i += 1
}
}
private func drawCaption(_ reading: Weather.Reading?) {
withState {
noStroke()
textFont(OutlineFont.systemMedium)
textAlign(.left, .top)
textSize(17)
fill(Color(white: 1).withAlpha(0.85))
if let reading {
drawText(headline(reading), 28, 26)
textSize(13)
fill(Color(white: 1).withAlpha(0.5))
drawText(status(), 28, 52)
} else if let problem = weather.problem {
drawText("Mexico City", 28, 26)
textSize(13)
fill(Color(red: 1, green: 0.55, blue: 0.45))
drawText("the weather is unhappy: \(problem)", 28, 52)
} else {
drawText("Mexico City", 28, 26)
textSize(13)
fill(Color(white: 1).withAlpha(0.5))
drawText("looking up the city, then asking for the sky over it…", 28, 52)
}
}
}
private func headline(_ reading: Weather.Reading) -> String {
let degrees = String(format: "%.0f", reading.temperature)
let wind = String(format: "%.0f", reading.windSpeed)
let clouds = String(format: "%.0f", reading.cloudCover * 100)
return "\(reading.name ?? "Mexico City") · \(degrees) °C, \(reading.condition) · wind \(wind) m/s from the \(compass(reading.windDirection)) · clouds \(clouds)%"
}
private func status() -> String {
if let problem = weather.problem { return "the weather is unhappy: \(problem)" }
guard let since = weather.timeSinceUpdate else { return "waiting" }
return "read \(Int(since))s ago, checking every \(Int(weather.interval / 60)) min"
}
/// A heading in degrees as the point of the compass it is nearest.
private func compass(_ degrees: Double) -> String {
let points = ["north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest"]
let index = Int((degrees / 45).rounded()) % 8
return points[(index + 8) % 8]
}
}