Drawing maps with Swift Charts

7 min read––– views

Drawing maps with Swift Charts

In a previous post we drew Github contribution graph with Swift Charts. It is a powerful framework that lets you visualize data in various ways. In the Swift Charts: Vectorized and function plots WWDC session I came across an interesting example: drawing maps.

US map

I was inspired by this example and decided to draw a different map. I live in Kazakhstan, so let's visualize population data of Kazakhstan regions. Here's a reference chart from an official statistics site:

Kazakhstan map Source: gov.kz

Getting data

Initially, we need to get information about region coordinates, names and population. The best format for it is GeoJSON — an open standard format designed for representing simple geographical features. I used overpass turbo service to retrieve relevant coordinates and information. You can use a special query language to get data. Here's a query to get all regions of Kazakhstan:

[out:json];
{{geocodeArea:Kazakhstan}}->.searchArea;
relation["admin_level"="4"](area.searchArea);
out geom;

As a result you will get a JSON file with all regions of Kazakhstan in a special format. I modified it slightly, removed cities to simplify the drawing, and updated the population with actual values.

To process this file, we can use MKGeoJSONDecoder from MapKit framework. It allows to decode GeoJSON data into a collection of MKGeoJSONFeature objects.

import MapKit

func loadFeatureData() throws -> [FeatureData] {
    let url = Bundle.main.url(forResource: "kazakhstan",
                              withExtension: "geojson")!
    let kazakhstanData = try Data(contentsOf: url)
    let features = try MKGeoJSONDecoder()
        .decode(kazakhstanData)
        .compactMap { geoJSONObject in
            if let feature = geoJSONObject as? MKGeoJSONFeature {
                return FeatureData(feature: feature)
            }
            return nil
        }
    return features
}

Additionally, we need to create FeatureData struct to get information about each region. Apple engineers recommend preparing all data before drawing charts to prevent performance issues.

import MapKit

struct FeatureData: Identifiable {

    let id: String
    let coordinates: [CLLocationCoordinate2D]
    let center: CLLocationCoordinate2D
    let name: String?
    var population: Int = 0

    init?(feature: MKGeoJSONFeature) {
        guard let properties = feature.properties,
              let polygon = feature.geometry.first as? MKPolygon else {
            return nil
        }
        id = feature.identifier ?? UUID().uuidString
        coordinates = polygon.coordinates
        center = polygon.center
        let propertiesData = try? JSONDecoder().decode([String: String].self, from: properties)
        name = propertiesData?["name:en"]
        if let rawPopulation = propertiesData?["population"], let population = Int(rawPopulation) {
            self.population = population
        }
    }
}

In our case, all features contain only one geometry object — MKPolygon. With a small extension, we can extract all coordinates and determine the center of the region:

extension MKPolygon {

    var coordinates: [CLLocationCoordinate2D] {
        var coordinates = [CLLocationCoordinate2D](repeating: kCLLocationCoordinate2DInvalid,
                                                   count: pointCount)
        getCoordinates(&coordinates, range: NSRange(location: 0, length: pointCount))
        return coordinates
    }

    /// The center calculation algorithm is simple and may not work for regions with complex shapes.
    var center: CLLocationCoordinate2D {
        var minLat = CLLocationDegrees.greatestFiniteMagnitude
        var minLng = CLLocationDegrees.greatestFiniteMagnitude
        var maxLat = -CLLocationDegrees.greatestFiniteMagnitude
        var maxLng = -CLLocationDegrees.greatestFiniteMagnitude

        for coordinate in coordinates {
            minLat = min(minLat, coordinate.latitude)
            minLng = min(minLng, coordinate.longitude)
            maxLat = max(maxLat, coordinate.latitude)
            maxLng = max(maxLng, coordinate.longitude)
        }
        return CLLocationCoordinate2D(latitude: (minLat + maxLat) / 2,
                                      longitude: (minLng + maxLng) / 2)
    }
}

We also use feature.properties to retrieve the name and population of the region. Now we can draw a chart with map data.

Drawing chart

Let's prepare a content view:

import SwiftUI
import Charts
import MapKit

struct ContentView: View {

    @State private var features: [FeatureData] = []

    var body: some View {
        Chart {
            // Chart code goes here
        }
        .onAppear {
            do {
                features = try loadFeatureData()
            } catch {
                print("Error parsing GeoJSON: \(error)")
            }
        }
    }
}

To mimic the reference chart, we'll draw different components:

  • Colored areas with AreaPlot;
  • White lines with LinePlot;
  • Points with PointMark.
️ℹ️

AreaPlot and LinePlot are available since iOS 18. If you're using an older version, you can use AreaMark and LineMark instead.

For each component we create a separate ChartContent view to decompose drawing logic. Let's start with colored areas:

import Charts

struct FeatureAreaPlot: ChartContent {

    let featureData: FeatureData

    var body: some ChartContent {
        AreaPlot(featureData.coordinates,
                 x: .value("Longitude", \.longitude),
                 y: .value("Latitude", \.latitude),
                 stacking: .unstacked)
        .foregroundStyle(by: .value("Population", featureData.population))
    }
}

We set x and y values as PlottableValue with key paths to CLLocationCoordinate2D properties. Also we set a stacking parameter to unstacked to draw each region separately. To set a color we use foregroundStyle(by:) modifier with a PlottableValue for population. Let's add these views to the chart:

Chart {
    ForEach(features) { featureData in
        FeatureAreaPlot(featureData: featureData)
    }
}
.chartYScale(domain: 40...56)
.chartXScale(domain: 40...95)
.chartXAxis(.hidden)
.chartYAxis(.hidden)
.chartLegend(.hidden)
.chartPlotStyle {
    $0.aspectRatio(2, contentMode: .fit)
}

Additionally we change scales to center our map, hides unnecessary axes and legend, and set a fixed aspect ratio. Here's a result:

Area

You may notice strange artifacts in the drawing — two lines at the right. As I understood during debugging, different areas somehow affect each other. I didn't find a solution to fix it, but I'm almost sure it depends on the coordinates. But it's hard to find a mistake in such a big dataset.

The next part is to draw white lines. We can use LinePlot for this:

import Charts

struct FeatureLinePlot: ChartContent {

    let featureData: FeatureData

    var body: some ChartContent {
        LinePlot(featureData.coordinates,
                 x: .value("Longitude", \.longitude),
                 y: .value("Latitude", \.latitude)
        )
        .lineStyle(.init(lineWidth: 1, lineCap: .round, lineJoin: .round))
        .foregroundStyle(by: .value("Feature", featureData.id))
    }
}

The code looks similar to the previous one. We use LinePlot to draw a line with a specified line style. Foreground style prevents the line from being connected for a separate regions. Let's add this view to the chart:

Chart {
    ForEach(features) { featureData in
        FeatureAreaPlot(featureData: featureData)
    }
    ForEach(features) { featureData in
        FeatureLinePlot(featureData: featureData)
    }
}

We split the marks into two parts to draw lines above the areas. Since iOS 17 you can use zIndex modifier for each mark to control the order of drawing. However, it doesn't work in this case. If you know how to fix it, feel free to share your solution on X.

Now we have a map with colored areas and white lines:

Lines

The last part is to draw points. We can use PointMark for this:

import SwiftUI
import Charts

struct FeaturePointMark: ChartContent {

    let featureData: FeatureData

    var body: some ChartContent {
        PointMark(x: .value("x", featureData.center.longitude),
                  y: .value("y", featureData.center.latitude))
        .symbol {
            Circle()
                .stroke(.white, lineWidth: 8)
                .fill(.blue)
                .frame(width: 20, height: 20)

        }
        .annotation(position: .top, alignment: .center, spacing: 4) {
            if let name = featureData.name {
                Text(name)
                    .font(.caption2)
            }
        }
        .annotation(position: .bottom, alignment: .center, spacing: 4) {
            Text(featureData.population, format: .number)
                .font(.caption2)
        }
    }
}

Here we use three modifiers for customization:

  • symbol to set a custom view for the point;
  • annotation to add a top text with the region name;
  • annotation to add a bottom text with the population.

Adding to the chart is similar to the previous steps, so let's check the result:

Points

Conclusion

In this post we learned how to draw maps with Swift Charts. The final result is not perfect, but I discovered a lot of interesting and unobvious cases that can be useful in the future. Swift Charts has a lot of potential. Charts can be interactive; we can enhance the example above and add a region selection. Moreover, Swift Charts supports accessibility, and we can add VoiceOver to make the map more accessible.

I hope you enjoyed this example and will try to draw your own maps. As usual, you can find the final project on Github. If you have any questions or suggestions, feel free to share them on X. Thanks for reading!