Skip to content

How to Build Bar, Line, Sparkline and Radar Charts with Swift Charts

Musklr's workout overview chart with weekly bars and a trend line, beside the hand-drawn muscle balance radar.

Three months of training, fourteen bars, one trend line.

The shape is not an accident. A bar answers "how much did I do in this period" and a line answers "which way is this going", and the overview screen had to answer both, so it carries a rolling average over the bars rather than choosing between them.

That is the argument of this whole post in one image. A chart earns its place when it answers the question the person opening the screen actually has, and the form follows from the question rather than from what looks good. Musklr draws five kinds of chart, and each one exists because a different question needed a different shape.

What follows is which chart answers which question, how to build each one in Swift Charts including the one Swift Charts will not draw for you, and the craft that decides whether a chart is readable once it is on screen. All the code is from the shipping app, and there is a playground near the end that assembles the whole overview chart in one file you can paste into Xcode.

The chart's job decides its form

Every chart answers a question, and different questions want different shapes. Before picking one it helps to write down what the person opening the screen actually wants to know. Here is what we ship and, more usefully, where each one stops working.

Bar, line, sparkline, radar and anatomical heatmap charts compared by the question they answer and where they stop working
Chart The question it answers Where it stops working
Bar How much, per period? Trend. Bars make you do the smoothing in your head.
Line Which direction, over time? Discrete comparison. A line implies continuity between points that may be weeks apart.
Sparkline Roughly how is this going, at a glance, inline? Anything requiring a value. It has no axes by design.
Radar Am I balanced across a fixed set? Magnitude. It is a shape, not a measurement.
Heatmap Which muscles have I trained? How much. Colour intensity is a weak quantitative channel.

Bars answer how much, lines answer which way

These are different questions, and the overview chart needed both. The bars give you a period you can point at. The line gives you the direction you cannot see when fourteen bars go up and down.

Three months of workout duration in Musklr, bucketed into fourteen weekly bars with a rolling average drawn over them.
Weekly bars for the amount, a rolling average for the direction.
/// Centered rolling mean, one output per input. The window shrinks at the
/// edges to whatever neighbours exist, so the line spans the full chart
/// instead of starting late and ending early.
static func rollingAverage(_ values: [Double], window: Int) -> [Double] {
  guard !values.isEmpty, window > 0 else { return [] }
  let radius = window / 2

  return values.indices.map { index in
    let lower = max(values.startIndex, index - radius)
    let upper = min(values.index(before: values.endIndex), index + radius)
    let slice = values[lower...upper]
    return slice.reduce(0, +) / Double(slice.count)
  }
}

Let the window shrink at the edges. A fixed window that starts the line at index two makes the trend begin late and end early, and a gap at the end of a chart reads as missing data rather than as smoothing. Clamping the slice to whatever neighbours exist keeps the line spanning the whole plot.

The line is orange over blue bars, and dashed. Both of those are accessibility decisions rather than aesthetic ones. Same hue at reduced opacity makes a line disappear exactly where it crosses a bar, which is where a trend line gets read most. Blue and orange differ on the blue and yellow axis that red-green colour blindness leaves intact, and the dash pattern is a second channel that survives being flattened to greyscale.

A radar's axes have to be fixed

This is the one worth getting right first. If a radar's axes come from the data, for example the user's top eight muscle groups this month, then two periods are not comparable. A shape drawn in June and a shape drawn in July have different spokes, so the difference between them means nothing. Comparability is the only thing a radar does better than a bar chart, and top-N axes throw it away.

We fold twenty-seven seeded muscle groups onto eight fixed axes. Fifteen ids map onto an axis and twelve are deliberately excluded.

/// The axis set is fixed rather than derived from whatever the user trained
/// most, so a polygon drawn for one period is directly comparable with one
/// drawn for another. Case order is spoke order: clockwise from the top.
enum MuscleRadarGroup: String, CaseIterable, Identifiable {
  case back, glutes, hamstrings, quadriceps
  case biceps, triceps, shoulders, chest

  /// Matched by id, not name: the seed names are English, and renaming one
  /// must not silently empty an axis.
  var muscleGroupIds: Set<Int> {
    switch self {
    case .back:      return [9, 12, 13, 24, 26]
    case .glutes:    return [14, 22]
    case .shoulders: return [1, 23, 25]
    case .chest:     return [2]
    // ...
    }
  }
}

List the exclusions explicitly rather than letting unmapped ids fall through. That is what lets a test fail loudly when the seed gains a group, instead of the group quietly disappearing from the chart. Nobody files a bug about a muscle group that stopped being counted.

Count distinct sessions per axis rather than summing per-group counts. That is what keeps a five-member axis honest: one session of rows, shrugs and hyperextensions trains three groups that all fold into Back, and summing would score it as three back sessions.

Musklr's muscle balance radar with eight fixed axes: back, glutes, hamstrings, quadriceps, biceps, triceps, shoulders and chest.
The same eight spokes every time, so two periods can be compared by shape alone.

A sparkline is a glance, not a chart

Ours is about 100 by 30 points in an exercise list row. No axes, no labels, no values. Its only job is telling you whether something is going up, down or nowhere, and it earns its place by being readable without being looked at properly.

Give it a fixed domain anchored to now. Two sessions on a free scale stretch edge to edge and imply a density that never happened, and centring the window on the data loses recency, which is most of what the line is for.

/// Thirteen whole weeks, so a once-a-week trainer gets thirteen evenly
/// spaced sessions rather than a count that shifts with the hour of the day.
static let sparklineWindowDays = 91

/// The last `sparklineWindowDays`, ending at the present rather than
/// centred on the data.
static func sparklineWindow(lastPoint: Date, now: Date = Date()) -> ClosedRange<Date> {
  let end = max(lastPoint, now)
  return end.addingTimeInterval(-TimeInterval(sparklineWindowDays) * 24 * 3600)...end
}

Ninety-one days rather than three months, because month arithmetic and seconds arithmetic disagree by up to three days depending on which months the window spans. Thirteen whole weeks also means a once-a-week trainer gets thirteen evenly spaced points instead of a count that shifts with the hour of the day. Share the window with the query that fetches the data, so the days drawn are exactly the days fetched.

Anchored this way, an exercise last trained thirteen weeks ago hugs the left edge with empty space after it, and one trained yesterday runs to the right edge. Centred, both would look identical.

An exercise list in Musklr where each row carries a small sparkline showing that exercise's recent trend.
One session draws a bare dot. A history draws a line you can read without looking at it properly.

The one that is not a Swift Charts chart at all

Musklr also draws an anatomical heatmap: a body figure with each muscle tinted by how much it has been trained. It answers a question none of the charts above can, because the anatomy is the axis. Nobody has to learn a mapping first.

It is here as a contrast rather than as a lesson. There is no Chart, no mark and no Canvas geometry behind it. It is an illustration with tinted overlays, so there is nothing in it to teach about Swift Charts. That is the point worth taking: when the question is "where", the answer may not be a chart at all, and reaching for a charting library would have made it worse.

Front and back body figures in Musklr with muscles tinted by how much they were trained.
Musklr's muscle heatmap. No charting library involved: the anatomy is the axis.

Building them in Swift Charts

BarMark, LineMark, RectangleMark, PointMark and AreaMark cover four of the five. The radar is the exception, and the reason is simple: Swift Charts has no polar or radar mark. There is no plugin and no escape hatch. You draw it yourself in a Canvas.

The geometry is not hard, but two details decide whether it comes out right. Spoke zero sits at negative pi over two so the first axis points up, and angles advance clockwise because SwiftUI's y-axis grows downward. Write the same formula as a maths textbook would and the chart comes out mirrored.

/// Angle of spoke `index`, measured so spoke 0 points straight up and the
/// rest advance clockwise.
private func angle(at index: Int) -> Double {
  guard !axes.isEmpty else { return 0 }
  return (Double(index) / Double(axes.count)) * 2 * .pi - .pi / 2
}

private func point(center: CGPoint, radius: CGFloat, index: Int, scale: Double) -> CGPoint {
  let theta = angle(at: index)
  let distance = radius * CGFloat(scale)
  return CGPoint(
    x: center.x + CGFloat(cos(theta)) * distance,
    y: center.y + CGFloat(sin(theta)) * distance
  )
}

The rest of the radar is rings, spokes and a filled polygon, all drawn from that one point function at different scales. The fiddly part is the labels rather than the chart. They sit outside the rings, so the room they need comes off the radius. Solve each spoke against its own label instead of against a single worst-case inset: a long label on a diagonal spoke has the corner to grow into and should not shrink the chart the way the same label at three o'clock must.

Drag to read on an iOS 16.1 floor

Musklr's deployment target is iOS 16.1, and chartXSelection is iOS 17 and later, so selection is hand-rolled. A zero minimum distance drag maps the touch's x through the chart proxy to a date, and the nearest point by time wins.

/// Resets itself the moment the gesture ends *or* is cancelled. An
/// `onEnded`-cleared `@State` would miss every cancel and leave the callout
/// stranded on screen.
@GestureState private var dragX: CGFloat?

content
  .contentShape(Rectangle())
  .gesture(
    // Zero minimum distance means a tap also selects, which is what a user
    // expects when they poke a chart rather than drag across it.
    DragGesture(minimumDistance: 0)
      .updating($dragX) { value, state, _ in state = value.location.x }
  )

Zero minimum distance means a tap selects too, which is what people expect when they poke a chart rather than drag across it. Use @GestureState rather than @State so the selection resets the moment the gesture ends or is cancelled. A system gesture taking over, or a banner pulling down, would otherwise leave the callout stranded on screen.

The mapping itself is two calls in opposite directions. value(atX:) turns a touch into a domain value, and position(forX:) turns a domain value back into a screen position for drawing.

/// Nearest point in time to the dragged x position, by domain distance
/// rather than pixel distance so the result stays correct at any scale.
private func nearestPoint(toX x: CGFloat) -> ScrubPoint? {
  guard !points.isEmpty else { return nil }
  // `proxy` speaks plot-local coordinates; the overlay spans the whole chart.
  let plotLocalX = min(max(x - plotFrame.origin.x, 0), plotFrame.width)
  guard let date: Date = proxy.value(atX: plotLocalX) else { return nil }
  return points.min {
    abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date))
  }
}

Two things there are worth copying. Search by domain distance rather than pixel distance, so the result stays correct at any scale. And mind the coordinate spaces: the proxy answers in plot-local coordinates while the overlay spans the whole chart including the axis label column, so subtract plotFrame.origin before using the value. Skip that and every reading is silently offset by the width of your y-axis labels.

When you draw the selection, give the overlay an explicit stack. An overlay's alignment positions the group as a whole, and inside that group loose children lay out centred against the widest one, which for us is a fixed 160 point callout. A one point rule left implicit ends up half a callout away from the value it marks.

// Right. An explicit stack gives both children the same leading anchor.
.overlay(alignment: .topLeading) {
  ZStack(alignment: .topLeading) {
    if let selected {
      Rectangle().frame(width: 1, height: plotFrame.height)
      callout.frame(width: 160)
    }
  }
}

The craft that makes a chart readable

None of what follows is clever. It is bucket width, bar width, the domain, and how much text the axis is allowed to draw. Between them they decide whether the chart at the top of this post reads as a chart or as a picket fence.

Bucket to the range, not to the data

One bar per session is a resolution decision disguised as an honest one. Ninety marks across three hundred points cannot show a trend no matter how accurate each one is. Choose bucket width from the range the user selected instead.

/// Bucket width per range. Long ranges use wide buckets so bars stay legible
/// instead of collapsing into hairlines.
static func granularity(for range: TimeRange) -> ChartGranularity {
  switch range {
  case .oneWeek, .oneMonth:     return .day
  case .threeMonths, .sixMonths: return .week
  case .year, .allTime:         return .month
  }
}

Emit empty buckets

A week with no sessions has to exist as a zero rather than being skipped. Otherwise the rolling average quietly ignores the weeks the user missed, which is exactly the information the trend line exists to convey. A chart that drops the bad weeks is flattering and wrong.

var result: [ChartBucket] = []
var cursor = earliest
while cursor <= latest {
  guard let next = calendar.date(byAdding: component, value: 1, to: cursor) else { break }
  // Absent periods are emitted as zero rather than skipped.
  let entry = totals[cursor] ?? (0, 0, 0)
  result.append(
    ChartBucket(
      start: cursor,
      end: next,
      durationSeconds: entry.duration,
      volume: entry.volume,
      sessionCount: entry.count
    )
  )
  cursor = next
}

State the bar's width as a date span

Reach for BarMark(width: .ratio(0.7)) and you get nothing at all. .ratio is a fraction of the scale's step, and a continuous date scale has no step to take a fraction of. Express the width in the same units as the axis instead.

/// Half the drawn width of a bar, as a time offset either side of its
/// bucket's start. The bar covers 70% of its period, leaving the rest as
/// the gap that separates it from its neighbours.
private func halfBarWidth(of bucket: ChartBucket) -> TimeInterval {
  bucket.end.timeIntervalSince(bucket.start) * 0.35
}

RectangleMark(
  xStart: .value(dateName, bucket.start.addingTimeInterval(-halfBarWidth(of: bucket))),
  xEnd:   .value(dateName, bucket.start.addingTimeInterval(halfBarWidth(of: bucket))),
  yStart: .value(valueName, 0),
  yEnd:   .value(valueName, value)
)

RectangleMark is the right mark for this beyond the width question: it is the only one whose four bounds are all data values, because BarMark's x-range initialisers take screen coordinates for y. Seventy percent of the period is where a bar reads as a bar and the gap still separates it from its neighbour.

Pad the domain by half a bucket at both ends

Marks are plotted at the bucket's start on a continuous scale, so each bar is centred on that instant and spills half its width either side. A domain that ends at the last bucket's start draws that final bar half outside the plot.

let bucketLength = last.end.timeIntervalSince(last.start)
let halfBucket = bucketLength / 2

// Half a bucket before the first start, half a bucket after the last.
let lower = first.start.addingTimeInterval(-first.end.timeIntervalSince(first.start) / 2)
let upper = last.start.addingTimeInterval(halfBucket)

Half a bucket, symmetrically, and no more. Padding out to the last bucket's end looks like the same fix and is not: it leaves a full empty bucket of gutter on the right and stops the trend line short of the plot edge.

Bring the tick count down as the text goes up

Axis labels are the one text in a chart that has to fit around the thing they annotate, so every point they gain comes straight off the plot. At accessibility sizes, uncapped y-axis labels will take most of a card's width and leave the bars as hairlines, which gives the reader a column of numbers and no chart.

A font cap alone will not save it. Swift Charts squeezes an axis label into its slot and then truncates it, and it never drops a mark to give the survivors room, so five dates across a card at an accessibility size come out as "2… 6… 1… 2…". Three whole labels carry more than five broken ones.

/// Labelled ticks an axis may carry when the reader is at `requested`.
///
/// Charts squeezes a label into its slot and then truncates it; it never
/// drops a mark to give the survivors room. So the count has to come down
/// as the text goes up.
func axisTickCount(at requested: DynamicTypeSize) -> Int {
  let effective = axisTypeSizeCeiling.map { min(requested, $0) } ?? requested
  switch self {
  case .card:
    if effective >= .xxxLarge { return 3 }
    if effective >= .xxLarge  { return 4 }
    return 5
  case .fullScreen:
    if effective >= .accessibility4 { return 4 }
    if effective >= .xxxLarge       { return 5 }
    return 7
  }
}
The same workout duration chart at a large accessibility text size, carrying three whole date labels instead of five truncated ones.
Fewer labels, all of them readable, and the bars keep their width.

How far it comes down is a property of the surface and not just of the text. A card's plot is around 270 points wide once the y-axis column is paid for, and a landscape one around 630. Give the full-screen surface a card's three ticks and you have undercut the reason to open it, because bigger labels and fewer of them is not an improvement.

One subtlety in that API. desiredCount is a hint, and Charts rounds it up to whatever date stride lands nearest, so asking for exactly the number your width can hold comes back over that number and truncates again. Ask for one band less.

Build these yourself in a playground

Each chart below is one self-contained file. Nothing imports anything from Musklr, there are no assets to download, and the sample data is generated in the file itself, so a paste is all it takes.

Set up an empty playground once and reuse it for all three:

  1. In Xcode, choose File, then New, then Playground.
  2. Pick the Blank template and set the platform to macOS. The charts are plain SwiftUI, so macOS gives you a live view without booting a simulator.
  3. Delete the one line the template puts in the file.
  4. Paste one of the files below.
  5. Show the live view with the toggle at the top right of the editor, or Editor, then Live View. The chart draws as soon as the playground finishes its first run.

1. Bars with a trend line over them

This is the overview chart in miniature: weekly bucketing, empty weeks emitted as zero, bars at seventy percent of their period, a domain padded half a bucket at each end, and a rolling average that spans the whole plot.

import Charts
import PlaygroundSupport
import SwiftUI

// MARK: - 1. Some sessions to draw (three months, with a two-week gap)

struct Session { let date: Date; let minutes: Double }

let sessions: [Session] = {
  let cal = Calendar.current
  let today = cal.startOfDay(for: Date())
  return stride(from: 90, through: 0, by: -1).compactMap { day -> Session? in
    guard day % 3 == 0, !(30...44).contains(day) else { return nil }
    let d = cal.date(byAdding: .day, value: -day, to: today)!
    return Session(date: d, minutes: Double(45 + (day % 7) * 6))
  }
}()

// MARK: - 2. Bucket to the range, and emit the empty weeks

struct Bucket: Identifiable {
  var id: Date { start }
  let start: Date, end: Date, minutes: Double
}

func weeklyBuckets(_ sessions: [Session], cal: Calendar = .current) -> [Bucket] {
  var totals: [Date: Double] = [:]
  for s in sessions {
    let week = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: s.date))!
    totals[week, default: 0] += s.minutes
  }
  guard let first = totals.keys.min(), let last = totals.keys.max() else { return [] }
  var out: [Bucket] = []
  var cursor = first
  while cursor <= last {
    let next = cal.date(byAdding: .weekOfYear, value: 1, to: cursor)!
    out.append(Bucket(start: cursor, end: next, minutes: totals[cursor] ?? 0))
    cursor = next
  }
  return out
}

// MARK: - 3. A trend that spans the whole chart

func rollingAverage(_ v: [Double], window: Int) -> [Double] {
  guard !v.isEmpty, window > 0 else { return [] }
  let r = window / 2
  return v.indices.map { i in
    let s = v[max(0, i - r)...min(v.count - 1, i + r)]
    return s.reduce(0, +) / Double(s.count)
  }
}

// MARK: - 4. The chart

struct BarTrendChart: View {
  let buckets = weeklyBuckets(sessions)

  var body: some View {
    let trend = rollingAverage(buckets.map(\.minutes), window: 5)
    let half = { (b: Bucket) in b.end.timeIntervalSince(b.start) * 0.35 }
    let lo = buckets.first!.start.addingTimeInterval(-half(buckets.first!))
    let hi = buckets.last!.start.addingTimeInterval(half(buckets.last!))

    Chart {
      ForEach(buckets) { b in
        RectangleMark(
          xStart: .value("Start", b.start.addingTimeInterval(-half(b))),
          xEnd: .value("End", b.start.addingTimeInterval(half(b))),
          yStart: .value("Minutes", 0),
          yEnd: .value("Minutes", b.minutes)
        )
        .foregroundStyle(.blue)
      }
      ForEach(buckets.indices, id: \.self) { i in
        LineMark(x: .value("Week", buckets[i].start), y: .value("Trend", trend[i]))
          .foregroundStyle(.orange)
          .lineStyle(StrokeStyle(lineWidth: 3, lineCap: .round, dash: [6, 4]))
          .interpolationMethod(.catmullRom)
      }
    }
    .chartXScale(domain: lo...hi)
    .chartXAxis { AxisMarks(values: .automatic(desiredCount: 5)) }
    .frame(width: 640, height: 260)
    .padding()
  }
}

PlaygroundPage.current.setLiveView(BarTrendChart())
The bar chart playground running in Xcode, with the code on the left and the live view on the right showing fourteen weekly bars under a dashed orange trend line.
The same file in Xcode. The live view updates on every edit, which is what makes the experiments below worth doing.

The sample data is twenty-six sessions across three months with a deliberate two week gap in the middle. It buckets down to fourteen bars, and those two empty weeks come through as zeros, which is what drags the trend line down through the centre of the chart.

Now break it, one line at a time, and watch the live view answer:

  • Delete the guard that skips days 30 to 44 and the gap fills in. The trend flattens, which is the difference between a chart that admits you missed a fortnight and one that hides it.
  • Swap the RectangleMark for BarMark(x:y:) with width: .ratio(0.7) and the bars vanish entirely, because a continuous date scale has no step for the ratio to be a fraction of.
  • Change 0.35 to 0.05 and the picket fence is back.
  • Drop the half(...) terms from lo and hi and the outermost bars lose half of themselves over the plot edges.

2. The radar Swift Charts will not draw for you

No Chart, no marks. This one is a Canvas: concentric rings, eight spokes and a filled polygon, all placed by the one angle function.

import PlaygroundSupport
import SwiftUI

// Swift Charts has no polar mark, so a radar is drawn by hand in a Canvas.

struct Axis: Identifiable { var id: String { label }; let label: String; let value: Double }

let axes = [
  Axis(label: "Back", value: 0.74), Axis(label: "Glutes", value: 0.36),
  Axis(label: "Hamstrings", value: 0.26), Axis(label: "Quadriceps", value: 0.59),
  Axis(label: "Biceps", value: 0.46), Axis(label: "Triceps", value: 0.51),
  Axis(label: "Shoulders", value: 0.64), Axis(label: "Chest", value: 0.97),
]

struct RadarChart: View {
  let axes: [Axis]
  var rings = 4

  // Spoke 0 points straight up; the rest advance clockwise because SwiftUI's
  // y-axis grows downward. Write this like a maths textbook and it mirrors.
  func angle(_ i: Int) -> Double { (Double(i) / Double(axes.count)) * 2 * .pi - .pi / 2 }

  func point(_ c: CGPoint, _ radius: CGFloat, _ i: Int, _ scale: Double) -> CGPoint {
    let t = angle(i)
    return CGPoint(x: c.x + cos(t) * radius * scale, y: c.y + sin(t) * radius * scale)
  }

  var body: some View {
    GeometryReader { geo in
      let c = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2)
      // The inset has to clear the widest label, not just the ring. The real
      // app solves each spoke against its own label; a flat inset is the
      // playground-sized version of the same idea.
      let r = min(geo.size.width, geo.size.height) / 2 - 72
      Canvas { ctx, _ in
        for ring in 1...rings {                       // guide rings
          var p = Path()
          for i in axes.indices {
            let v = point(c, r, i, Double(ring) / Double(rings))
            i == 0 ? p.move(to: v) : p.addLine(to: v)
          }
          p.closeSubpath()
          ctx.stroke(p, with: .color(.gray.opacity(0.35)), lineWidth: 1)
        }
        for i in axes.indices {                       // spokes
          var p = Path(); p.move(to: c); p.addLine(to: point(c, r, i, 1))
          ctx.stroke(p, with: .color(.gray.opacity(0.25)), lineWidth: 1)
        }
        var poly = Path()                             // the value polygon
        for (i, a) in axes.enumerated() {
          let v = point(c, r, i, min(max(a.value, 0), 1))
          i == 0 ? poly.move(to: v) : poly.addLine(to: v)
        }
        poly.closeSubpath()
        ctx.fill(poly, with: .color(.blue.opacity(0.35)))
        ctx.stroke(poly, with: .color(.blue), lineWidth: 2)
      }
      ForEach(Array(axes.enumerated()), id: \.element.id) { i, a in
        Text(a.label).font(.caption2).foregroundStyle(.secondary)
          .position(point(c, r + 22, i, 1))
      }
    }
    .frame(width: 420, height: 420)
  }
}

PlaygroundPage.current.setLiveView(RadarChart(axes: axes))
A radar chart with eight labelled axes drawn in a SwiftUI Canvas, showing an uneven blue polygon.
Change the sign in the angle function and the whole chart mirrors. That is the detail worth feeling once.

Try setting every value to the same number. You get a regular octagon, which is what balance looks like and is also the quickest way to check your geometry is right. Then change -.pi / 2 to +.pi / 2 and watch the chart flip, which is the mistake a maths textbook will lead you into.

3. A sparkline, and why the window is anchored to now

Two rows, two exercises, one scale. The only difference between them is when they were last trained.

import Charts
import PlaygroundSupport
import SwiftUI

// A sparkline is a glance, not a chart: no axes, no labels, ~100x30pt.

struct Point: Identifiable { let id = UUID(); let date: Date; let value: Double }

func series(_ days: [Int], _ values: [Double]) -> [Point] {
  let today = Calendar.current.startOfDay(for: Date())
  return zip(days, values).map {
    Point(date: Calendar.current.date(byAdding: .day, value: -$0, to: today)!, value: $1)
  }
}

// Trailing 91 days ending now, NOT centred on the data: centring loses recency.
func window(_ points: [Point]) -> ClosedRange<Date> {
  let end = max(points.map(\.date).max() ?? Date(), Date())
  return end.addingTimeInterval(-91 * 24 * 3600)...end
}

struct Sparkline: View {
  let points: [Point]
  var body: some View {
    Chart(points) {
      LineMark(x: .value("Day", $0.date), y: .value("Value", $0.value))
        .foregroundStyle(.blue)
        .interpolationMethod(.monotone)
    }
    .chartXScale(domain: window(points))
    .chartXAxis(.hidden)
    .chartYAxis(.hidden)
    .frame(width: 100, height: 30)
  }
}

struct SparklineDemo: View {
  var body: some View {
    VStack(alignment: .leading, spacing: 18) {
      HStack { Text("Trained recently").frame(width: 150, alignment: .leading)
        Sparkline(points: series([84, 70, 56, 42, 28, 14, 2], [60, 62, 65, 64, 70, 74, 78])) }
      HStack { Text("Stopped 10 weeks ago").frame(width: 150, alignment: .leading)
        Sparkline(points: series([88, 84, 78, 72, 70], [40, 44, 46, 45, 48])) }
    }
    .font(.caption)
    .padding()
  }
}

PlaygroundPage.current.setLiveView(SparklineDemo())
Two sparklines sharing one scale. The recently trained exercise runs to the right edge; the one stopped ten weeks ago hugs the left with empty space after it.
One reaches the right edge, one stops short. That gap is the information.

Delete the .chartXScale line and both lines stretch edge to edge, identical, and the fact that one of those exercises has not been touched in ten weeks disappears from the chart.

Landscape earns its place for time series

A rotated full-screen chart is not a luxury when the x-axis is time and the phone is 393 points wide. Doubling the plot width roughly doubles how many labelled ticks you can carry, and it is the one surface where axis labels can grow to whatever size the reader asked for.

Rotating back is the part that needs care. UIKit caches each controller's answer to supportedInterfaceOrientations and only re-asks when a controller says the answer changed, so flipping the value your app delegate reads is not enough on its own. Walk the presentation chain first, then request the geometry update.

// UIKit caches each controller's answer to `supportedInterfaceOrientations`
// and only re-asks when a controller says the answer changed. Without this
// walk, the return to portrait is refused and the app stays landscape.
var controller = (scene.keyWindow ?? scene.windows.first)?.rootViewController
while let current = controller {
  current.setNeedsUpdateOfSupportedInterfaceOrientations()
  controller = current.presentedViewController
}

scene.requestGeometryUpdate(.iOS(interfaceOrientations: orientations)) { error in
  Logger.log(
    "Chart orientation request refused: \(error.localizedDescription)",
    category: .app, type: .info)
}
Musklr's full screen chart in landscape with a scrub rule and a callout reading May 31, 305.2 minutes.
Twice the width buys roughly twice the labelled ticks. The rule sits centred on the bar it marks.

Call that restore from onDisappear rather than from your close button. The close button is the only exit a user can see, but it is not the only way the chart leaves the screen, and a programmatic dismissal would otherwise leave the app sideways.

If you test rotation, assert on XCUIDevice and the window frame rather than on anything about a screenshot. simctl io screenshot returns a portrait pixel buffer whatever the interface orientation is, so a landscape screen captures as a portrait image containing sideways UI, and the image dimensions tell you nothing about orientation.

Where to spend your time

Pick the chart that answers the question your user arrived with. Then spend your time on bucketing, domain padding and Dynamic Type, because those are what people feel even when they cannot name them.

A chart is not a picture of a table. It is the shortest path between a question someone has and an answer they can see without being taught how to read it.

Free on iPhone and Apple Watch. Log a set in two taps.

See what Musklr does

Curious how the rest of the app is built? Read how we built the rest timer animation.