Readings
a year of readings drawn as a range chart from a CSV file: typed reads by column name, a scale derived from numbers(_:), and notes with commas and quotation marks in them, which survive the parse
- Run
swift run --package-path Examples Example-Data-Readings- Beside it
- readings.csv
- Source
- Examples/Data/Readings/Sketch.swift
import Ollin
/// A year of readings drawn straight out of a CSV file.
///
/// `loadTable` reads the file once in `setup()`; everything the drawing needs
/// comes back out of it by column name. Each month is a bar from its low to its
/// high, painted with a color the file itself carries, over a rain dot sized by
/// the rain column. The vertical scale comes from `numbers(_:)`, which reads a
/// whole column as a series, so the chart fits whatever the file holds rather
/// than a range typed in here.
///
/// The notes are where the format earns its rules. Several hold a comma, one
/// holds quotation marks, and neither breaks a row, because a quoted cell may
/// hold the separator and a doubled quote inside one stands for a single quote.
/// Hover a month to read its note and see it come through intact.
///
/// The readings are invented. What is worth copying is the reading, not the
/// weather.
@main
final class Readings: Sketch {
private var table: Table?
/// The value range the bars are drawn against, padded a little so nothing
/// touches the edge of the plot.
private var low = 0.0
private var high = 1.0
/// The rain range, so the dots below the chart differ by enough to read.
/// Sized against a fixed formula they all come out nearly the same circle,
/// which makes a real column look like decoration.
private var leastRain = 0.0
private var mostRain = 1.0
private var plot: Rectangle {
Rectangle(x: 130, y: 250, width: width - 260, height: height - 490)
}
private var plotRight: Double { plot.x + plot.width }
private var plotBottom: Double { plot.y + plot.height }
override func setup() {
table = try? loadTable(resource: "readings", withExtension: "csv", in: .module)
// The scale is the file's, not the sketch's: read both columns as
// series and take the extremes.
guard let table else { return }
let highs = table.numbers("high")
let lows = table.numbers("low")
guard let top = highs.max(), let bottom = lows.min(), top > bottom else { return }
let padding = (top - bottom) * 0.18
high = top + padding
low = bottom - padding
let rain = table.numbers("rain")
if let least = rain.min(), let most = rain.max(), most > least {
leastRain = least
mostRain = most
}
}
override func draw() {
background(Color(hex: 0x14161C))
guard let table, !table.isEmpty else {
return drawStatus("readings.csv could not be read", style: .warning)
}
drawGuides()
let hovered = hoveredMonth(in: table)
for (index, row) in table.enumerated() {
drawMonth(row, at: index, of: table.count, emphasized: index == hovered)
}
drawTitle()
if let hovered { drawNote(table[hovered]) }
drawCaption("a year of readings read from a CSV; hover a month for its note")
}
// MARK: One month
private func drawMonth(_ row: Table.Row, at index: Int, of count: Int, emphasized: Bool) {
let x = plot.x + plot.width * (Double(index) + 0.5) / Double(count)
let tint = row.color("tint") ?? Color(white: 0.6)
// The rain dot sits under the bar, sized by how much fell.
if let rain = row.number("rain") {
noStroke()
fill(tint.withAlpha(0.16))
drawCircle(x, plotBottom + 50, map(rain, leastRain, mostRain, 7, 24))
}
// The bar spans low to high. A missing number leaves the month out
// rather than drawing it at zero, which would read as a real reading.
guard let top = row.number("high"), let bottom = row.number("low") else { return }
noFill()
stroke(emphasized ? tint : tint.withAlpha(0.85))
strokeWeight(emphasized ? 26 : 20)
strokeCap(.round)
drawLine(x, y(for: top), x, y(for: bottom))
noStroke()
fill(Color(white: emphasized ? 0.95 : 0.55))
textFont(OutlineFont.systemMedium)
textSize(15 * scale)
textAlign(.center, .top)
drawText(row["month"] ?? "", x, plotBottom + 84)
if emphasized {
fill(.white)
textSize(13 * scale)
textAlign(.center, .bottom)
drawText(format(top), x, y(for: top) - 20)
textAlign(.center, .top)
drawText(format(bottom), x, y(for: bottom) + 20)
}
}
// MARK: The frame around it
/// Faint horizontal rules every five degrees, labeled at the left.
private func drawGuides() {
let step = 5.0
var value = (low / step).rounded(.up) * step
textFont(OutlineFont.systemMedium)
textSize(12 * scale)
while value <= high {
let lineY = y(for: value)
// The zero line is the one worth seeing.
let zero = abs(value) < 0.001
stroke(Color(white: 1, alpha: zero ? 0.24 : 0.08))
strokeWeight(1)
drawLine(plot.x - 40, lineY, plotRight + 20, lineY)
noStroke()
fill(Color(white: zero ? 0.7 : 0.4))
textAlign(.right, .middle)
drawText(format(value), plot.x - 54, lineY)
value += step
}
}
private func drawTitle() {
noStroke()
fill(.white)
textFont(OutlineFont.system)
textSize(30 * scale)
textAlign(.left, .top)
drawText("A year of readings", plot.x - 54, 96)
fill(Color(white: 0.45))
textFont(OutlineFont.systemMedium)
textSize(14 * scale)
drawText("daily high and low, with rainfall below", plot.x - 54, 142)
}
/// The hovered month's note, which is the cell holding the commas.
private func drawNote(_ row: Table.Row) {
guard let note = row["note"], !note.isEmpty else { return }
noStroke()
fill(row.color("tint") ?? .white)
textFont(OutlineFont.systemMedium)
textSize(17 * scale)
textAlign(.right, .top)
drawText("\u{201C}\(note)\u{201D}", plotRight + 20, 106)
}
// MARK: Reading the mouse
/// Which month the pointer is over, by nearest column.
private func hoveredMonth(in table: Table) -> Int? {
guard mouseX >= plot.x, mouseX <= plotRight, !table.isEmpty else { return nil }
let position = (mouseX - plot.x) / plot.width * Double(table.count)
return min(table.count - 1, max(0, Int(position)))
}
// MARK: Mapping
private func y(for value: Double) -> Double {
map(value, low, high, plotBottom, plot.y)
}
private func format(_ value: Double) -> String {
String(format: "%.0f", value)
}
}