# Swift Charts Patterns

Companion to the `swift-charts` skill. Any source file touching these types
imports `Charts` next to `SwiftUI`.

## Contents

- [Modelling the Data](#modelling-the-data)
- [Bars](#bars)
- [Lines](#lines)
- [Pies and Donuts](#pies-and-donuts)
- [Combining Marks](#combining-marks)
- [A Tooltip on Selection](#a-tooltip-on-selection)
- [Scrolling with a Bound Position](#scrolling-with-a-bound-position)
- [Function Plots (iOS 18)](#function-plots-ios-18)
- [3D Charts and Surfaces (iOS 26)](#3d-charts-and-surfaces-ios-26)
- [Accessibility](#accessibility)
- [Dynamic Type and Colour](#dynamic-type-and-colour)
- [Performance: When to Vectorize](#performance-when-to-vectorize)
- [Dark Mode and Theming](#dark-mode-and-theming)
- [Heat Map](#heat-map)
- [Stacking](#stacking)
- [`MarkDimension`](#markdimension)
- [Symbols](#symbols)
- [`ChartProxy` and Coordinates](#chartproxy-and-coordinates)
- [Modifier Quick Reference](#modifier-quick-reference)
- [Apple Documentation](#apple-documentation)

## Modelling the Data

```swift
import Charts
import SwiftUI

struct MonthlyUsage: Identifiable, Sendable {
    let id = UUID()
    let month: Date
    let kilowattHours: Double
}

@MainActor
@Observable
final class UsageModel {
    private(set) var months: [MonthlyUsage] = []

    func load() async {
        months = await UsageService.shared.lastTwelveMonths()
    }
}

struct UsageChart: View {
    @State private var model = UsageModel()

    var body: some View {
        Chart(model.months) { entry in
            BarMark(x: .value("Month", entry.month, unit: .month),
                    y: .value("kWh", entry.kilowattHours))
        }
        .task { await model.load() }
    }
}
```

The view owns the model with `@State` and starts loading from `.task`.

## Bars

Side by side instead of stacked: combine `.position(by:)` with
`.foregroundStyle(by:)`.

```swift
Chart(results) { row in
    BarMark(x: .value("Quarter", row.quarter), y: .value("Units", row.units))
        .foregroundStyle(by: .value("Model", row.model))
        .position(by: .value("Model", row.model))
}
```

Horizontal bars with only value labels on the category axis:

```swift
Chart(languages) { item in
    BarMark(x: .value("Speakers", item.speakers), y: .value("Language", item.name))
}
.chartYAxis {
    AxisMarks { _ in AxisValueLabel() }
}
```

Shares of 100 percent per bar: `BarMark(x:y:stacking: .normalized)`.

A value on top of each bar:

```swift
BarMark(x: .value("Store", row.store), y: .value("Sales", row.sales))
    .annotation(position: .top) {
        Text(row.sales, format: .currency(code: "EUR").precision(.fractionLength(0)))
            .font(.caption2)
    }
```

A Gantt chart colours interval bars by status:

```swift
Chart(tasks) { task in
    BarMark(xStart: .value("Start", task.start),
            xEnd: .value("End", task.end),
            y: .value("Task", task.title))
        .foregroundStyle(by: .value("Phase", task.phase.rawValue))
}
```

## Lines

Points on the line: put a `LineMark` and a `PointMark` in the same closure.

```swift
Chart(temperatures) { sample in
    LineMark(x: .value("Hour", sample.hour), y: .value("Celsius", sample.value))
        .foregroundStyle(by: .value("City", sample.city))
        .symbol(by: .value("City", sample.city))
    PointMark(x: .value("Hour", sample.hour), y: .value("Celsius", sample.value))
        .symbolSize(30)
}
```

Pairing `.foregroundStyle(by:)` with `.symbol(by:)` keeps series apart for
people who cannot tell the colours apart.

A line over a fading area:

```swift
Chart(prices) { point in
    AreaMark(x: .value("Day", point.day), y: .value("Price", point.close))
        .foregroundStyle(.linearGradient(
            colors: [.indigo.opacity(0.35), .indigo.opacity(0.02)],
            startPoint: .top, endPoint: .bottom))
    LineMark(x: .value("Day", point.day), y: .value("Price", point.close))
        .foregroundStyle(.indigo)
}
```

Interpolation methods:

| Method | Result |
|--------|--------|
| `.linear` | Straight segments (the default) |
| `.monotone` | Smooth without overshooting; keeps monotonic data monotonic |
| `.catmullRom` | Smooth curve for general data |
| `.cardinal` | Smooth curve with adjustable tension |
| `.stepStart`, `.stepCenter`, `.stepEnd` | Steps placed at the start, middle or end of each interval |

A sparkline:

```swift
Chart(recent) { point in
    LineMark(x: .value("t", point.index), y: .value("v", point.value))
        .interpolationMethod(.catmullRom)
}
.chartXAxis(.hidden)
.chartYAxis(.hidden)
.chartLegend(.hidden)
.frame(width: 80, height: 30)
```

## Pies and Donuts

An inner radius of 0.618 (the golden ratio) gives a balanced donut. Put a label
in the hole with `.chartBackground`:

```swift
Chart(portfolio) { holding in
    SectorMark(angle: .value("Value", holding.value), innerRadius: .ratio(0.618))
        .foregroundStyle(by: .value("Asset", holding.asset))
}
.chartBackground { _ in
    VStack {
        Text("Total").font(.caption)
        Text(portfolioTotal, format: .currency(code: "EUR")).font(.headline)
    }
}
```

Selecting a slice. The binding holds a position along the summed values, so
map it back to a slice:

```swift
struct PortfolioDonut: View {
    let holdings: [Holding]
    @State private var selectedAngle: Double?

    private var selectedHolding: Holding? {
        guard let angle = selectedAngle else { return nil }
        var start = 0.0
        for holding in holdings {
            let end = start + holding.value
            if (start..<end).contains(angle) { return holding }
            start = end
        }
        return nil
    }

    var body: some View {
        Chart(holdings) { holding in
            SectorMark(angle: .value("Value", holding.value), innerRadius: .ratio(0.6))
                .foregroundStyle(by: .value("Asset", holding.asset))
                .opacity(selectedHolding == nil || selectedHolding?.id == holding.id ? 1 : 0.4)
        }
        .chartAngleSelection(value: $selectedAngle)
    }
}
```

Keep five to seven positive slices:

```swift
func topSlices(_ items: [Holding], keep limit: Int = 5) -> [Holding] {
    let ordered = items.filter { $0.value > 0 }.sorted { $0.value > $1.value }
    let head = Array(ordered.prefix(limit))
    let remainder = ordered.dropFirst(limit).reduce(0) { $0 + $1.value }
    guard remainder > 0 else { return head }
    return head + [Holding(asset: "Other", value: remainder)]
}
```

## Combining Marks

A range band with an average line:

```swift
Chart(week) { day in
    AreaMark(x: .value("Day", day.date),
             yStart: .value("Low", day.low),
             yEnd: .value("High", day.high))
        .opacity(0.15)
    LineMark(x: .value("Day", day.date), y: .value("Mean", day.mean))
        .lineStyle(StrokeStyle(lineWidth: 2))
}
```

Bars with a target line:

```swift
Chart {
    ForEach(weeks) { week in
        BarMark(x: .value("Week", week.label), y: .value("Hours", week.hours))
    }
    RuleMark(y: .value("Target", 38))
        .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4]))
        .annotation(position: .top, alignment: .leading) { Text("38 h") }
}
```

Observations with a fitted curve; marks and plots can share a chart:

```swift
Chart {
    ForEach(samples) { sample in
        PointMark(x: .value("Dose", sample.dose), y: .value("Response", sample.response))
    }
    LinePlot(x: "Dose", y: "Response", domain: 0...10) { dose in
        fit.slope * dose + fit.intercept
    }
    .foregroundStyle(.red)
    .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
}
```

## A Tooltip on Selection

```swift
struct ActivityChart: View {
    let days: [ActivityDay]
    @State private var focusedDate: Date?

    private var focusedDay: ActivityDay? {
        guard let focusedDate else { return nil }
        let calendar = Calendar.current
        return days.first { calendar.isDate($0.date, inSameDayAs: focusedDate) }
    }

    var body: some View {
        Chart {
            ForEach(days) { day in
                LineMark(x: .value("Date", day.date, unit: .day),
                         y: .value("Minutes", day.minutes))
            }
            if let focusedDay {
                RuleMark(x: .value("Date", focusedDay.date, unit: .day))
                    .foregroundStyle(.secondary)
                PointMark(x: .value("Date", focusedDay.date, unit: .day),
                          y: .value("Minutes", focusedDay.minutes))
                    .symbolSize(80)
                    .annotation(position: .top) {
                        Text("\(focusedDay.minutes) min")
                            .padding(6)
                            .background(.regularMaterial, in: .rect(cornerRadius: 6))
                    }
            }
        }
        .chartXSelection(value: $focusedDate)
    }
}
```

## Scrolling with a Bound Position

`chartScrollPosition(x:)` takes a binding to a non-optional `Plottable` value,
so bind a `Date`, not a `Date?`:

```swift
@State private var windowStart = Date.now.addingTimeInterval(-7 * 86_400)

Chart(readings) { reading in
    BarMark(x: .value("Day", reading.date, unit: .day),
            y: .value("Litres", reading.litres))
}
.chartScrollableAxes(.horizontal)
.chartXVisibleDomain(length: 3600 * 24 * 7)
.chartScrollPosition(x: $windowStart)
.chartXAxis {
    AxisMarks(values: .stride(by: .day)) { value in
        AxisGridLine()
        AxisValueLabel(format: .dateTime.weekday(.abbreviated))
    }
}
```

## Function Plots (iOS 18)

```swift
Chart {
    LinePlot(x: "Angle", y: "Amplitude", domain: -2 * .pi ... 2 * .pi) { cos($0) }
}
.chartYScale(domain: -1.2...1.2)

Chart {
    LinePlot(x: "Across", y: "Up", t: "Turn", domain: 0...(2 * .pi)) { turn in
        (x: 2 * cos(turn), y: 2 * sin(turn))
    }
}
.chartXScale(domain: -2.5...2.5)
.chartYScale(domain: -2.5...2.5)

Chart {
    AreaPlot(x: "x", yStart: "lower", yEnd: "upper", domain: 0...4) { x in
        (yStart: x - 0.5, yEnd: x * x / 2 + 0.5)
    }
}
```

The last chart fills the band between two functions of x.

## 3D Charts and Surfaces (iOS 26)

`Chart3D` is for data with a real third dimension: points in (x, y, z), 3D
regions, or a surface of two variables. Categories, time series and shares stay
in 2D: labels, comparisons and VoiceOver all work better there.

```swift
struct SensorCloud: View {
    let readings: [SensorReading]

    var body: some View {
        Chart3D(readings) { reading in
            PointMark(x: .value("X", reading.x),
                      y: .value("Y", reading.y),
                      z: .value("Z", reading.z))
                .foregroundStyle(by: .value("Sensor", reading.sensor))
        }
        .chart3DCameraProjection(.perspective)
    }
}
```

Review notes:
- The z axis must mean something and carry a label.
- Set explicit x, y and z domains so charts can be compared.
- Bind a `Chart3DPose` when users should rotate the chart.
- `SurfacePlot` draws y as a function of x and z; the 3D mark initialisers
  draw observed points and regions.

## Accessibility

- Swift Charts builds VoiceOver descriptions from axis labels and values, so
  the strings in `.value("Label", ...)` are what VoiceOver says. Make them
  descriptive.
- Add `.accessibilityLabel` and `.accessibilityValue` to marks when the
  default is not clear enough.
- Vectorized plots take key paths:
  `.accessibilityLabel(\.spokenName)` and `.accessibilityValue(\.spokenValue)`.
- The system creates an audio graph for VoiceOver users automatically; clear,
  consistent labels make it useful.
- Move through every element with VoiceOver. Never mark data-bearing marks
  `.accessibilityHidden(true)`.

## Dynamic Type and Colour

- Axis labels follow Dynamic Type. Avoid fixed heights; give a range such as
  `.frame(minHeight: 200)` with `.frame(maxHeight: 400)`.
- Check axes, annotations and legends at the largest accessibility size
  (Accessibility Extra Extra Extra Large).
- Colour must not be the only carrier of meaning. Pair
  `.foregroundStyle(by:)` with `.symbol(by:)`, `.lineStyle(by:)` or both.
- Prefer system colours, which adapt, and check colour-blindness filters in
  Accessibility Inspector.

## Performance: When to Vectorize

| Points | Use |
|--------|-----|
| Fewer than 100 | Individual marks |
| 100 to 1,000 | Either; profile if it feels slow |
| More than 1,000 | Vectorized plots |

```swift
struct TemperatureSample: Identifiable {
    let id: Int
    let time: Date
    let celsius: Double
    var tint: Color { celsius > 30 ? .red : .blue }
    var spokenValue: Text { Text("\(celsius, specifier: "%.1f") degrees") }
}

Chart {
    LinePlot(samples, x: .value("Time", \.time), y: .value("Celsius", \.celsius))
        .foregroundStyle(\.tint)
        .accessibilityValue(\.spokenValue)
}
```

| Vectorized (iOS 18) | Mark equivalent |
|---------------------|-----------------|
| `BarPlot` | `BarMark` |
| `LinePlot` | `LineMark` |
| `PointPlot` | `PointMark` |
| `AreaPlot` | `AreaMark` |
| `RulePlot` | `RuleMark` |
| `RectanglePlot` | `RectangleMark` |
| `SectorPlot` | `SectorMark` |

## Dark Mode and Theming

Charts follow the colour scheme, and system colours adapt on their own.

```swift
Chart(spending) { entry in
    BarMark(x: .value("Period", entry.period), y: .value("Spent", entry.amount))
        .foregroundStyle(by: .value("Kind", entry.kind))
}
.chartForegroundStyleScale([
    "Food": Color.orange,
    "Travel": Color.teal,
    "Home": Color.purple,
])
.chartPlotStyle { plot in
    plot.background(.quaternary.opacity(0.3))
        .border(.separator, width: 0.5)
}
.chartXAxisStyle { axis in
    axis.background(.thinMaterial)
}
```

`chartForegroundStyleScale` fixes one colour per category across the app.
Preview both schemes (`.preferredColorScheme(.dark)` in a `#Preview`) and check
that labels and grid lines stay readable, data keeps its contrast, and
annotations and legends adapt.

## Heat Map

```swift
Chart(bookings) { cell in
    RectangleMark(x: .value("Hour", cell.hour), y: .value("Weekday", cell.weekday))
        .foregroundStyle(by: .value("Bookings", cell.count))
}
.chartForegroundStyleScale(range: Gradient(colors: [.mint, .yellow, .orange]))
```

## Stacking

Pass `stacking:` to `BarMark` or `AreaMark`:

| Value | Effect |
|-------|--------|
| `.standard` | Absolute values stacked on each other (default) |
| `.normalized` | Each stack fills 0 to 100 percent |
| `.center` | Stacks centred on a baseline, as in a streamgraph |
| `.unstacked` | Series overlap instead of stacking |

## `MarkDimension`

`.automatic`, `.fixed(CGFloat)`, `.inset(CGFloat)` and `.ratio(CGFloat)` with a
ratio between 0 and 1. Used for `width` and `height` on `BarMark` and for
`innerRadius` and `outerRadius` on `SectorMark`.

## Symbols

The built-in shapes are `asterisk`, `circle`, `cross`, `diamond`, `pentagon`,
`plus`, `square` and `triangle`.

```swift
PointMark(x: .value("Speed", run.speed), y: .value("Heart rate", run.bpm))
    .symbol(.diamond)
    .symbolSize(80)

PointMark(x: .value("Speed", run.speed), y: .value("Heart rate", run.bpm))
    .symbol(by: .value("Surface", run.surface))

PointMark(x: .value("Speed", run.speed), y: .value("Heart rate", run.bpm))
    .symbol { Image(systemName: "figure.run").font(.caption2) }
```

## `ChartProxy` and Coordinates

`chartOverlay` and `chartBackground` pass a `ChartProxy` that converts between
screen positions and data values. Since iOS 17, `plotFrame` (an optional
anchor) and `plotSize` replace the deprecated `plotAreaFrame` and
`plotAreaSize`.

```swift
.chartOverlay { proxy in
    GeometryReader { geometry in
        Rectangle()
            .fill(.clear)
            .contentShape(Rectangle())
            .gesture(
                DragGesture(minimumDistance: 0).onChanged { drag in
                    guard let plotFrame = proxy.plotFrame else { return }
                    let origin = geometry[plotFrame].origin
                    let x = drag.location.x - origin.x
                    scrubbedDate = proxy.value(atX: x, as: Date.self)
                }
            )
    }
}
```

Proxy methods: `position(forX:)`, `position(forY:)`, `value(atX:as:)`,
`value(atY:as:)`, plus `plotSize` and `plotFrame`.

## Modifier Quick Reference

| Area | Modifiers |
|------|-----------|
| Axes | hide or replace: `chartYAxis(_:)`, `chartXAxis(_:)`, and the builder forms `chartYAxis(content:)`, `chartXAxis(content:)`; titles: `chartYAxisLabel`, `chartXAxisLabel`; axis area styling: `chartYAxisStyle(content:)`, `chartXAxisStyle(content:)` |
| Scales | `chartYScale(domain:range:type:)`, `chartXScale(domain:range:type:)`; for `Chart3D` also `chartZScale(domain:range:type:)`; categorical colours with `chartForegroundStyleScale(_:)` |
| 3D | `chart3DCameraProjection(_:)` (perspective or orthographic), `chart3DPose(_:)` |
| Legend | `chartLegend(_:)` for visibility; `chartLegend(position:alignment:spacing:)` for placement; add `content:` for a custom legend view |
| Selection (iOS 17) | a single value: `chartXSelection(value:)`, `chartYSelection(value:)`; a span: `chartXSelection(range:)`, `chartYSelection(range:)`; pie slices: `chartAngleSelection(value:)` |
| Scrolling (iOS 17) | turn on with `chartScrollableAxes(_:)`; window size with `chartXVisibleDomain(length:)` or `chartYVisibleDomain(length:)`; position with `chartScrollPosition(initialX:)` or a bound `chartScrollPosition(x:)`; snapping with `chartScrollTargetBehavior(_:)` |
| Plot area and layers | `chartPlotStyle(content:)`, `chartBackground(alignment:content:)`, `chartOverlay(alignment:content:)` |

## Apple Documentation

- [Swift Charts](https://developer.apple.com/documentation/charts)
- [Building a first chart](https://developer.apple.com/documentation/charts/creating-a-chart-using-swift-charts)
- [BarMark](https://developer.apple.com/documentation/charts/barmark)
- [LineMark](https://developer.apple.com/documentation/charts/linemark)
- [SectorMark](https://developer.apple.com/documentation/charts/sectormark)
- [Chart3D](https://developer.apple.com/documentation/charts/chart3d)
- [SurfacePlot](https://developer.apple.com/documentation/charts/surfaceplot)
- [LinePlot](https://developer.apple.com/documentation/charts/lineplot)
- [AxisMarks](https://developer.apple.com/documentation/charts/axismarks)
- [Swift Charts updates](https://developer.apple.com/documentation/updates/swiftcharts)
