Connect with us

Разработка

Рисуем карты с помощью Swift Charts

В этом посте мы узнали, как рисовать карты с помощью Swift Charts. Конечный результат не идеален, но я обнаружил много интересных и неочевидных моментов, которые могут быть полезны в будущем.

Опубликовано

/

     
     

Артем Новичков, iOS-разработчик в Welltory, поделился интересной функцией Swift Charts — оказывается, с их помощью можно рисовать карты.

Swift Charts — это мощный фреймворк, позволяющий визуализировать данные различными способами. На сессии WWDC «Swift Charts: векторные и функциональные графики» я наткнулся на интересный пример: отрисовку карт.

Рисуем карты с помощью Swift Charts

Я вдохновился этим примером и решил нарисовать другую карту. Я живу в Казахстане, поэтому давайте визуализируем данные о населении казахстанских регионов. Вот справочная диаграмма с официального сайта статистики:

Рисуем карты с помощью Swift Charts

Источник: gov.kz

Получение данных

Изначально нам нужно получить информацию о координатах, названиях и населении региона. Лучше всего для этого подходит формат GeoJSON — открытый стандартный формат, предназначенный для представления простых географических объектов. Я использовал сервис Overpass для получения соответствующих координат и информации. Для получения данных можно использовать специальный язык запросов. Вот запрос для получения всех регионов Казахстана:

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

В результате вы получите JSON-файл со всеми регионами Казахстана в специальном формате. Я немного изменил его, убрал города, чтобы упростить отрисовку, и обновил численность населения на актуальные значения.

Для обработки этого файла мы можем использовать MKGeoJSONDecoder из фреймворка MapKit. Он позволяет декодировать данные GeoJSON в коллекцию объектов MKGeoJSONFeature.

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
}

Кроме того, нам нужно создать структуру FeatureData, чтобы получить информацию о каждом регионе. Инженеры Apple рекомендуют подготавливать все данные перед построением графиков, чтобы избежать проблем с производительностью.

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
        }
    }
}

В нашем случае все объекты содержат только один геометрический объект — MKPolygon. С помощью небольшого расширения мы можем извлечь все координаты и определить центр области:

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)
    }
}

Мы также используем feature.properties, чтобы получить название и население региона. Теперь мы можем построить график с картой.

Построение графика

Давайте подготовим представление для контента:

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)")
            }
        }
    }
}

Чтобы имитировать оригинальную карту, мы будем рисовать различные компоненты:

  • Цветные области с помощью AreaPlot;
  • Белые линии с помощью LinePlot;
  • Точки с помощью PointMark.

AreaPlot и LinePlot доступны начиная с iOS 18. Если вы ориентируетесь на более старые версии, вместо них можно использовать AreaMark и LineMark.

Для каждого компонента мы создаем отдельное представление ChartContent, чтобы разложить логику отрисовки на составляющие. Начнем с цветных областей:

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))
    }
}

Мы задаем значения x и y как PlottableValue с ключевыми путями из свойств CLLocationCoordinate2D. Также мы установили параметр stacking в значение unstacked, чтобы рисовать каждый регион отдельно. Чтобы задать цвет, мы используем модификатор foregroundStyle(by:) с PlottableValue для населения. Добавим эти представления на график:

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)
}

Кроме того, мы изменим масштаб, чтобы отцентрировать карту, скроем ненужные оси и легенду, а также установим фиксированное соотношение сторон. Вот результат:

Рисуем карты с помощью Swift Charts

Вы вероятно заметили странные артефакты на рисунке — две линии справа. Как я понял в процессе отладки, разные области каким-то образом влияют друг на друга. Я не нашел решения, как это исправить, но почти уверен, что это зависит от координат. Но в таком большом наборе данных трудно найти ошибку.

Следующая часть — нарисовать белые линии. Для этого мы можем использовать LinePlot:

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))
    }
}

Код выглядит так же, как и предыдущий. Мы используем LinePlot для отрисовки линии с заданным стилем. Стиль Foreground предотвращает соединение линий для разных областей. Добавим это представление на график:

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

Мы разделили вывод на две части, чтобы нарисовать линии над областями. Начиная с iOS 17 вы можете использовать модификатор zIndex для каждой метки, чтобы управлять порядком отрисовки. Однако в данном случае он не работает. Если вы знаете, как это исправить, не стесняйтесь поделиться своим решением в X.

Теперь у нас есть карта с цветными областями и белыми линиями:

Рисуем карты с помощью Swift Charts

Последняя часть — это отрисовка точек. Для этого мы можем использовать PointMark:

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)
        }
    }
}

Здесь мы используем три модификатора:

  • symbol для установки кастомного представления точки;
  • annotation для добавления верхнего текста с названием региона;
  • annotation для добавления нижнего текста с населением.

Добавление на график аналогично предыдущим шагам, поэтому давайте проверим результат:

Рисуем карты с помощью Swift Charts

Заключение

В этом посте мы узнали, как рисовать карты с помощью Swift Charts. Конечный результат не идеален, но я обнаружил много интересных и неочевидных моментов, которые могут быть полезны в будущем. У Swift Charts большой потенциал. Диаграммы могут быть интерактивными; мы можем усовершенствовать приведенный выше пример и добавить выбор региона. Более того, Swift Charts поддерживает доступность, и мы можем добавить VoiceOver, чтобы сделать карту более доступной.

Надеюсь, вам понравился этот пример, и вы попробуете нарисовать свои собственные карты. Как обычно, финальный проект вы можете найти на GitHub. Если у вас есть вопросы или предложения, не стесняйтесь делиться ими на X. Спасибо, что читаете!

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: