RSS Feed ↗
VOL. 1 • NO. 14 • IOSBUFFET.COM OCT 25, 2026
Inside Page — A13 Continued from Front
The Daily Recipe
Oct 15, 2026 • Page A13

3D Charts: A Surface You Can Spin

Swift Charts gained a third axis in iOS 26. Plot an oven's heat as a surface, float a batch of cookie test bakes in a 3D scatter, and let the reader turn both with a finger.

By M. HOSSAIN — Staff Writer — Oct 15, 2026 2230 words
Recipe Card — No. 8
Serves
iOS 26+ · iPhone, iPad and Mac
Prep time
30 minutes
Kitchen-tested
Compiled with Xcode 26.6 · iOS 26.5 simulator · Oct 26, 2026
Difficulty
★★☆
Ingredients
  • Chart3D
  • SurfacePlot with .foregroundStyle(.heightBased)
  • PointMark(x:y:z:) with per-point colors
  • .chart3DPose(_:) with a Chart3DPose binding
  • .chart3DCameraProjection(_:)
  • .chartZScale(domain:)

Some data has three dimensions and a flat chart hides one of them. In iOS 26, Swift Charts adds Chart3D: the same declarative marks you know from 2D charts, plus a z axis, rendered in real 3D and rotatable with a drag.

This recipe makes two dishes from the kitchen. One is a heat map of an oven rack as a 3D surface, showing the hot spot near the back element. The other is a scatter plot of cookie test bakes: bake time and dough thickness along the floor, taste rating as height, and oven temperature as color, with buttons to snap the camera to the front or top view.

Method

1. Set out the data

Chart3D lives in Charts, and the pose angles use Angle2D from Apple’s Spatial framework, so import both. The cookie data is twenty test batches, each with a taste score from 1 to 5. The scores are made up for the recipe, peaking around 11 minutes, 180 °C and 8 mm.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import Charts
import Spatial
import SwiftUI

struct CookieBatch: Identifiable {
    let id: Int
    let minutes: Double
    let temperature: Double
    let thickness: Double
    let rating: Int

    static let samples: [CookieBatch] = (0..<20).map { i -> CookieBatch in
        let minutes = 8 + Double(i % 5) * 1.5
        let temperature = 160 + Double(i / 5) * 15
        let thickness = 6 + Double((i * 3) % 5)
        let miss = abs(minutes - 11) / 1.5 + abs(temperature - 180) / 15 + abs(thickness - 8) / 2
        return CookieBatch(
            id: i, minutes: minutes, temperature: temperature, thickness: thickness,
            rating: max(1, 5 - Int(miss.rounded()))
        )
    }
}

2. Plot a surface

SurfacePlot draws a function of two variables. One detail to get right: in Swift Charts 3D, y is the vertical axis, so the function takes x and z and returns y. Here x and z are positions on a 60 by 40 cm oven rack and y is the temperature there, rising toward the back with a hot spot near the back-left corner.

.foregroundStyle(.heightBased) colors the surface by its y value, which makes the shape easy to read from any angle. Binding a Chart3DPose to .chart3DPose(_:) is all it takes to make the chart interactive: the reader drags to turn it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct OvenHeatMap: View {
    @State private var pose: Chart3DPose = .default

    var body: some View {
        Chart3D {
            SurfacePlot(x: "Width (cm)", y: "Temperature (°C)", z: "Depth (cm)") { x, z in
                let hotSpot = exp(-(pow(x - 15, 2) + pow(z - 35, 2)) / 300)
                return 168 + 0.3 * z + 35 * hotSpot
            }
            .foregroundStyle(.heightBased)
        }
        .chartXScale(domain: 0...60)
        .chartZScale(domain: 0...40)
        .chartYScale(domain: 160...220)
        .chart3DPose($pose)
        .aspectRatio(1, contentMode: .fit)
    }
}

Set all three scales explicitly, so the chart covers the rack’s real 60 by 40 cm and a fixed temperature range rather than whatever range the data happens to span. The .aspectRatio(1, contentMode: .fit) is there because, in our runs, a 3D chart given a tall space drew itself wider than the screen and lost its edges; constraining it to a square that fits the width keeps the whole box and its labels in view.

3. Scatter points in 3D

PointMark gains an init(x:y:z:) for 3D charts. Two lessons from the test kitchen shaped this one.

First, mind the proportions. In our simulator runs, Chart3D sized its box from the axis domains in their own units, so a first draft with temperature on the y axis (a 65-degree span) next to bake time (8 minutes) came out as a tall, thin column. Putting the taste rating on y instead gives three axes with similar spans (7, 6 and 5), and the best batches literally rise to the top.

Second, color. A 3D PointMark has .foregroundStyle(by:) like its 2D cousin, but in our runs, coloring by the numeric rating left every point the same blue. Setting each point’s color directly with .foregroundStyle(_:) works, and it lets temperature be the color here.

The pose doesn’t have to come from a drag. Chart3DPose has presets such as .front, .top and .default, plus init(azimuth:inclination:) for a custom angle. Setting it from a button jumps the camera, and the reader can still drag from there. The camera projection is a setting too: .perspective makes nearer points look bigger, and .orthographic keeps sizes constant, which makes it easier to compare positions along an axis.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
struct CookieTrials: View {
    let batches: [CookieBatch]
    @State private var pose = Chart3DPose(azimuth: .degrees(35), inclination: .degrees(20))
    @State private var isPerspective = true

    var body: some View {
        VStack(spacing: 16) {
            Chart3D(batches) { batch in
                PointMark(
                    x: .value("Bake time (min)", batch.minutes),
                    y: .value("Rating", batch.rating),
                    z: .value("Thickness (mm)", batch.thickness)
                )
                .foregroundStyle(Self.color(for: batch.temperature))
            }
            .chartXScale(domain: 7.5...14.5)
            .chartYScale(domain: 0...5)
            .chartZScale(domain: 5...11)
            .chart3DPose($pose)
            .chart3DCameraProjection(isPerspective ? .perspective : .orthographic)

            HStack {
                Button("Front") { withAnimation { pose = .front } }
                Button("Top") { withAnimation { pose = .top } }
                Button("Reset") { withAnimation { pose = .default } }
                Spacer()
                Toggle("Perspective", isOn: $isPerspective)
                    .fixedSize()
            }
            .buttonStyle(.bordered)

            HStack(spacing: 12) {
                ForEach([160.0, 175, 190, 205], id: \.self) { temperature in
                    Label("\(Int(temperature)) °C", systemImage: "circle.fill")
                        .foregroundStyle(Self.color(for: temperature))
                }
            }
            .font(.caption)
        }
    }

    static func color(for temperature: Double) -> Color {
        switch temperature {
        case ..<170: .blue
        case ..<185: .green
        case ..<200: .orange
        default: .red
        }
    }
}

The row of labels under the chart is a hand-made legend, since the colors are set directly rather than through a scale.

4. Serve both on one screen

Dragging a 3D chart and scrolling a page fight over the same gesture, so don’t put these charts in a ScrollView. A segmented picker switches between them instead:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct ChartsKitchenScreen: View {
    enum Dish: String, CaseIterable, Identifiable {
        case oven = "Oven"
        case cookies = "Cookies"
        var id: Self { self }
    }

    @State private var dish = Dish.oven

    var body: some View {
        NavigationStack {
            VStack(spacing: 20) {
                Picker("Chart", selection: $dish) {
                    ForEach(Dish.allCases) { dish in
                        Text(dish.rawValue).tag(dish)
                    }
                }
                .pickerStyle(.segmented)

                switch dish {
                case .oven: OvenHeatMap()
                case .cookies: CookieTrials(batches: CookieBatch.samples)
                }
            }
            .padding()
            .navigationTitle(dish == .oven ? "Oven Heat Map" : "Cookie Trials")
        }
    }
}

Plating

The oven opens on a colored surface tilted toward you: cool colors along the front of the rack, warming toward the back, and a peak in the back-left corner where the element runs hottest. Drag across it to turn the rack and look at the hot spot from other sides. Switch to Cookies and twenty points float in a box: the higher a point, the better that batch tasted, and the colors show which oven temperature it came from. Tap Top and the camera looks straight down at bake time against thickness; tap Reset to go back.

Oven
Oven
Cookies
Cookies
From the top
From the top
FIG. 1 — The oven rack's temperature as a surface, and the cookie test bakes in 3D, seen from the default angle and from the top.

Kitchen notes

  • Chart3D is available on iOS, iPadOS, macOS and visionOS 26, not on tvOS or watchOS. chart3DCameraProjection is listed for iOS, iPadOS and macOS only, so leave it out of visionOS builds.
  • Apple’s documentation lists 3D initializers for PointMark, RuleMark and RectangleMark, alongside the 3D-only SurfacePlot.
  • In our runs the axes were titled X, Y and Z, not with the names passed to .value or SurfacePlot. Say what each axis means in the text or a caption around the chart.
  • .foregroundStyle(.normalBased) colors a surface by the direction it faces instead of its height, which helps when two surfaces overlap in the same chart.
  • A 3D chart is harder to read precisely than a 2D one. Pair it with a plain summary, or a 2D chart of the slice that matters, for readers who need exact values.

— 30 —

— END OF EDITION 2026 — SET IN GARAMOND & COURIER ON NEWSPRINT STOCK — iOS BUFFET PRESS — RSS —