11 H11: Grafiek en boxplot
Een klok laat zien wat het huidige uur kost. Maar wat als je de hele dag in één oogopslag wilt zien? Of de afgelopen maand vergelijken? In dit hoofdstuk bouw je twee visualisaties met Canvas: een staafdiagram voor de dagprijzen en een boxplot voor historische data.
11.1 Wat gaan we bouwen?
Twee schermen:
- PriceChartView — een staafdiagram van de uurprijzen van vandaag en morgen.
- BoxplotView — een box-and-whisker diagram van de afgelopen 60 dagen.

11.2 Playground-voorbeeld
import SwiftUI
import PlaygroundSupport
struct StaafDiagram: View {
let waarden: [Double] = [3, 7, 5, 12, 9, 4, 8]
var body: some View {
Canvas { context, size in
guard let maximum = waarden.max(), maximum > 0 else { return }
let breedte = size.width / Double(waarden.count)
let tussenruimte: CGFloat = 4
for (index, waarde) in waarden.enumerated() {
let hoogte = (waarde / maximum) * size.height
let x = Double(index) * breedte + tussenruimte / 2
let y = size.height - hoogte
let rechthoek = CGRect(x: x, y: y, width: breedte - tussenruimte, height: hoogte)
context.fill(Path(rechthoek), with: .color(.blue))
}
}
.frame(width: 300, height: 150)
.border(.gray.opacity(0.3))
}
}
PlaygroundPage.current.setLiveView(StaafDiagram())Zeven blauwe balken. Verander de waarden in waarden en de balken passen zich automatisch aan.
11.3 Concept uitgelegd
11.3.1 Een staafdiagram: schalen
Het lastigste aan een staafdiagram is schalen: de hoogste waarde moet de volledige hoogte invullen, en alles eronder wordt verhoudingsgewijs kleiner.
hoogte van balk = (waarde / maximum) × totale hoogte
Als de maximumprijs €0,40 is en de prijs van een uur €0,20 is, wordt de balk precies half zo hoog.
11.3.2 Boxplot: vijf getallen
Een boxplot (ook wel box-and-whisker diagram) vat een set getallen samen in vijf waarden:
whisker boven ─── maximum
│
box bovenrand ■ derde kwartiel (75%)
█
mediaan ─── middelste waarde
█
box onderrand ■ eerste kwartiel (25%)
│
whisker onder ─── minimum
De box laat zien waar het middelste deel van de data zit. De whiskers (de lijntjes) tonen het bereik. Als een dag goedkoop en stabiel is, is de box klein. Als de prijzen sterk variëren, is hij groot.
11.3.3 DailySummary: statistieken per dag
We berekenen de vijf waarden vooraf en bewaren ze als een DailySummary:
// Bron: EnergyClock/DailySummary.swift
struct DailySummary: Codable, Identifiable {
let date: Date
let low: Double // minimum
let q1: Double // 25e percentiel
let median: Double // mediaan
let q3: Double // 75e percentiel
let high: Double // maximum
let average: Double // gemiddelde
}11.4 Code schrijven
Stap 1: DailySummary aanmaken
Maak DailySummary.swift met een berekende initializer:
import Foundation
// Bron: EnergyClock/DailySummary.swift
struct DailySummary: Codable, Identifiable, Equatable {
var id: Date { date }
let date: Date
let low: Double
let q1: Double
let median: Double
let q3: Double
let high: Double
let average: Double
// Bereken statistieken uit een lijst van prijzen
static func berekendVanPrijzen(_ prijzen: [Double], datum: Date) -> DailySummary? {
guard !prijzen.isEmpty else { return nil }
let gesorteerd = prijzen.sorted()
let count = gesorteerd.count
func percentiel(_ p: Double) -> Double {
let index = p * Double(count - 1)
let onder = Int(index)
let boven = min(onder + 1, count - 1)
let fractie = index - Double(onder)
return gesorteerd[onder] + fractie * (gesorteerd[boven] - gesorteerd[onder])
}
return DailySummary(
date: datum,
low: gesorteerd.first!,
q1: percentiel(0.25),
median: percentiel(0.50),
q3: percentiel(0.75),
high: gesorteerd.last!,
average: prijzen.reduce(0, +) / Double(count)
)
}
}Stap 2: het staafdiagram
import SwiftUI
// Bron: EnergyClock/PriceChartView.swift
struct PrijsGrafiekView: View {
let prices: [EnergyPrice]
let colorMapper: EnergyColorMapper
var body: some View {
Canvas { context, size in
guard let maxPrijs = prices.compactMap({ $0.price }).max(), maxPrijs > 0 else { return }
let breedte = size.width / Double(prices.count)
let tussenruimte: CGFloat = 2
for (index, prijs) in prices.enumerated() {
guard let bedrag = prijs.price else { continue }
let hoogte = (bedrag / maxPrijs) * size.height * 0.9
let x = Double(index) * breedte + tussenruimte / 2
let y = size.height - hoogte
let rechthoek = CGRect(x: x, y: y, width: breedte - tussenruimte, height: hoogte)
let kleur = colorMapper.color(for: prijs)
context.fill(Path(rechthoek), with: .color(kleur))
}
}
.frame(maxWidth: .infinity, minHeight: 120)
.padding()
}
}Stap 3: de boxplot
import SwiftUI
// Bron: EnergyClock/BoxplotView.swift
struct BoxplotGrafiekView: View {
let summaries: [DailySummary]
let colorMapper: EnergyColorMapper
var body: some View {
Canvas { context, size in
guard !summaries.isEmpty else { return }
guard let maxPrijs = summaries.map({ $0.high }).max(), maxPrijs > 0 else { return }
let breedte = size.width / Double(summaries.count)
let schalingFactor = size.height * 0.85 / maxPrijs
for (index, dag) in summaries.enumerated() {
let x = Double(index) * breedte
let middenX = x + breedte / 2
let boxBreedte = breedte * 0.5
// Whiskers (verticale lijn min tot max)
let whiskerBoven = size.height - dag.high * schalingFactor
let whiskerOnder = size.height - dag.low * schalingFactor
var whisker = Path()
whisker.move(to: CGPoint(x: middenX, y: whiskerBoven))
whisker.addLine(to: CGPoint(x: middenX, y: whiskerOnder))
context.stroke(whisker, with: .color(.secondary.opacity(0.6)), lineWidth: 1)
// Box (q1 tot q3)
let boxBoven = size.height - dag.q3 * schalingFactor
let boxOnder = size.height - dag.q1 * schalingFactor
let boxRect = CGRect(x: middenX - boxBreedte / 2, y: boxBoven,
width: boxBreedte, height: boxOnder - boxBoven)
let kleur = colorMapper.color(for: dag.median)
context.fill(Path(boxRect), with: .color(kleur.opacity(0.4)))
context.stroke(Path(boxRect), with: .color(kleur), lineWidth: 1)
// Mediaan (horizontale lijn door de box)
let mediaanY = size.height - dag.median * schalingFactor
var mediaan = Path()
mediaan.move(to: CGPoint(x: middenX - boxBreedte / 2, y: mediaanY))
mediaan.addLine(to: CGPoint(x: middenX + boxBreedte / 2, y: mediaanY))
context.stroke(mediaan, with: .color(.primary), lineWidth: 1.5)
}
}
.frame(maxWidth: .infinity, minHeight: 150)
.padding()
}
}Het mediaan is robuuster dan het gemiddelde bij scheve data. Stel dat er een uur was met een extreme prijs van €2,00 (bijv. door netcongestie). Dat trekt het gemiddelde sterk omhoog, terwijl de mediaan nauwelijks beïnvloed wordt.
Het interkwartielsbereik (IQR = Q3 − Q1) beschrijft hoe stabiel de prijzen waren die dag. Een kleine IQR betekent stabiele prijzen; een grote IQR betekent veel schommeling.
De percentielen worden berekend met lineaire interpolatie: als het 25e percentiel precies tussen twee waarden valt, neem je het gewogen gemiddelde. Dit is de meestgebruikte methode (ook wel “methode 2” of “Type 7” in statistieksoftware als R).
In de EnergyClock worden deze statistieken ook gebruikt voor de relatieve kleurmodus (H5): p20 is de grens voor groen, p50 voor geel en p80 voor oranje.
11.5 Apple documentatie
Er is geen apart Apple-framework voor grafieken in Canvas — je bouwt alles zelf met Path en CGRect. Kijk voor de bouwstenen naar:
developer.apple.com/documentation/swiftui/path
developer.apple.com/documentation/corefoundation/cgrect
Wil je een grafiek bouwen zonder Canvas te gebruiken, kijk dan naar het Swift Charts-framework van Apple:
11.6 Samenvatting
| Begrip | Betekenis |
|---|---|
| Staafdiagram | Toont waarden als verticale balken op een as |
| Schalen | Waarden omzetten naar pixels zodat ze in de beschikbare ruimte passen |
| Boxplot | Toont de spreiding van een dataset met vijf statistieken |
| Mediaan | De middelste waarde van een gesorteerde dataset |
| Kwartiel | Verdeelt data in vier gelijke stukken (Q1=25%, Q3=75%) |
| Whisker | De verticale lijn in een boxplot die het totale bereik aangeeft |
CGRect |
Een rechthoek gedefinieerd door positie en afmetingen |
11.7 Opdracht
Pas het staafdiagram aan:
- Voeg een horizontale referentielijn toe op de gemiddelde prijs van de dag.
- Kleur de balk van het huidige uur anders (dikker of een andere kleur) zodat het opvalt.
- Voeg labels toe onder de balken voor de uren 0, 6, 12, 18 en 23.