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:

  1. PriceChartView — een staafdiagram van de uurprijzen van vandaag en morgen.
  2. BoxplotView — een box-and-whisker diagram van de afgelopen 60 dagen.

Een staafdiagram met 24 gekleurde balken voor de uurprijzen van vandaag

Staafdiagram van de uurprijzen

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()
    }
}
NoteVerdieping: percentielen en robuuste statistieken

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:

developer.apple.com/documentation/charts

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:

  1. Voeg een horizontale referentielijn toe op de gemiddelde prijs van de dag.
  2. Kleur de balk van het huidige uur anders (dikker of een andere kleur) zodat het opvalt.
  3. Voeg labels toe onder de balken voor de uren 0, 6, 12, 18 en 23.