Разработка
Визуализация данных с помощью шестиугольной тепловой карты в Swift Charts
В этой статье мы используем Swift Charts, чтобы отобразить землетрясения по всей Аотеароа в Новой Зеландии, с помощью шестиугольных ячеек.
При создании графиков для пространственных данных измерения часто агрегируют в ячейки примерно одинаковой площади, формируя тепловую карту. В этой статье мы используем Swift Charts, чтобы отобразить землетрясения по всей Аотеароа в Новой Зеландии, с помощью шестиугольных ячеек.
Шестиугольная сетка не только эффектно выглядит, но и может естественнее приближать контуры суши и политических границ, чем прямоугольная сетка. Здесь частота землетрясений определяет цвет каждой ячейки по симметричной логарифмической шкале.
Расположение точек в шестиугольной сетке
Обычная тепловая карта использует прямоугольные клетки, расположенные по строкам и столбцам. Шестиугольные ячейки образуют строки со смещением, которые можно описать с помощью аксиальных координат, идентифицируя каждую ячейку двумя целыми числами — q и r. Затем точка в каждом вычисленном центре формирует нашу сетку.
struct HexCell: Identifiable, Sendable {
struct ID: Hashable, Sendable {
let q: Int
let r: Int
}
let id: ID
let longitude: Double
let latitude: Double
let earthquakes: [Earthquake]
}
enum HexGrid {
static func center(
for id: HexCell.ID,
radius: Double
) -> (x: Double, y: Double) {
(
x: radius * sqrt(3) * (Double(id.q) + Double(id.r) / 2),
y: radius * 1.5 * Double(id.r)
)
}
}
Каждый шаг по q перемещает нас на одну ячейку по горизонтали, а каждый шаг по r — на следующую строку с горизонтальным смещением на половину ячейки. Вместе эти две координаты определяют центр каждой ячейки, а радиус задаёт расстояние между ними так, чтобы соседние шестиугольники точно стыковались. Это расположение можно проиллюстрировать центральной ячейкой и шестью её соседями при радиусе, равном единице.
let gridIDs = [
HexCell.ID(q: 0, r: 0), HexCell.ID(q: 1, r: 0),
HexCell.ID(q: 0, r: 1), HexCell.ID(q: -1, r: 1),
HexCell.ID(q: -1, r: 0), HexCell.ID(q: 0, r: -1),
HexCell.ID(q: 1, r: -1)
]
let gridCells = gridIDs.map { id in
let centre = HexGrid.center(for: id, radius: 1)
return HexCell(
id: id,
longitude: centre.x,
latitude: centre.y,
earthquakes: []
)
}
Chart {
PointPlot(
gridCells,
x: .value("Grid x", \.longitude),
y: .value("Grid y", \.latitude)
)
.symbolSize(45)
.foregroundStyle(.indigo)
}
Стандартные круглые символы в PointPlot позволяют увидеть сетку ещё до того, как мы добавим шестиугольную форму, а подписи координат показывают семь пар целых значений на диаграмме.

Подписи показывают, как q увеличивается слева направо внутри одной строки, а r — от нижней строки к верхней. Следуя за точками с одинаковым q, можно также увидеть горизонтальное смещение на половину ячейки, сопровождающее каждый шаг по r, которое и создаёт характерную шахматную структуру сетки.
Группировка наблюдений по ячейкам
Выборка данных о землетрясениях содержит 53 763 наблюдения за период с 2019 по 2024 год, подготовленные на основе каталога землетрясений GeoNet. Группировка наблюдений по ячейкам позволяет сравнивать частоту землетрясений по региону без наложения отдельных точек друг на друга, которое скрывало бы распределение.
Каждая загруженная запись содержит долготу, широту, магнитуду и глубину в километрах.
struct Earthquake: Identifiable, Sendable {
let id: String
let longitude: Double
let latitude: Double
let magnitude: Double
let depth: Double
}
Для простоты мы можем работать непосредственно с долготой и широтой, получая ячейки одинаковой площади в координатном пространстве. В зависимости от региона более подходящей может оказаться равноплощадная проекция. Обратив вычисление центра, мы получим дробные значения q и r, которые затем можно округлить, чтобы определить содержащую точку ячейку.
extension HexGrid {
static func cell(
x: Double,
y: Double,
radius: Double
) -> HexCell.ID {
rounded(
q: (sqrt(3) / 3 * x - y / 3) / radius,
r: (2 * y / 3) / radius
)
}
}
Независимое округление q и r может поместить наблюдение в неправильную ячейку возле наклонной границы. Мы можем добавить производную координату s = -q - r, которая ограничивает три значения так, чтобы их сумма всегда была равна нулю. После округления всех трёх координат корректировка той, у которой ошибка округления наибольшая, восстанавливает это ограничение.
private extension HexGrid {
static func rounded(q: Double, r: Double) -> HexCell.ID {
let x = q
let z = r
let y = -x - z
var roundedX = x.rounded()
var roundedY = y.rounded()
var roundedZ = z.rounded()
let xDifference = abs(roundedX - x)
let yDifference = abs(roundedY - y)
let zDifference = abs(roundedZ - z)
if xDifference > yDifference,
xDifference > zDifference {
roundedX = -roundedY - roundedZ
} else if yDifference > zDifference {
roundedY = -roundedX - roundedZ
} else {
roundedZ = -roundedX - roundedY
}
return HexCell.ID(
q: Int(roundedX),
r: Int(roundedZ)
)
}
}
Эта коррекция восстанавливает ограничение с нулевой суммой и возвращает пару целых чисел, идентифицирующих ближайшую шестиугольную ячейку. Теперь, имея стабильный идентификатор для каждой позиции, мы можем сгруппировать наблюдения по ячейкам с помощью словаря.
let grouped = Dictionary(grouping: earthquakes) { earthquake in
HexGrid.cell(
x: earthquake.longitude,
y: earthquake.latitude,
radius: hexRadius
)
}
Каждая запись словаря содержит землетрясения, отнесённые к одной ячейке, давая нам единственный центр для отображения и коллекцию, размер которой определяет частоту.
let cells = grouped.map { id, earthquakes in
let centre = HexGrid.center(for: id, radius: hexRadius)
return HexCell(
id: id,
longitude: centre.x,
latitude: centre.y,
earthquakes: earthquakes
)
}
В словарь попадают только ячейки, содержащие наблюдения, поэтому пустые области вообще не требуют точек на графике.

После группировки значение в каждом центре сетки представляет частоту землетрясений по всей ячейке, а не отдельное наблюдение в этой точке.
Отображение частоты землетрясений через размер точек
Для агрегированных ячеек можно использовать тот же PointPlot, что и для нашей пустой сетки. Прежде чем вводить шестиугольные символы, мы можем изменять размер кругов, чтобы ячейки с более высокой частотой землетрясений выглядели крупнее.
extension HexCell {
var earthquakeCount: Int { earthquakes.count }
}
Диапазоны долготы и широты определяют область, отображаемую на нашей тепловой карте. Если соотношение сторон графика совпадает с отношением ширин этих диапазонов, наши шестиугольники сохранят правильные пропорции и будут аккуратно стыковаться друг с другом.
let xDomain = 164.0...180.0
let yDomain = -48.5...(-33.5)
let aspectRatio =
(xDomain.upperBound - xDomain.lowerBound)
/ (yDomain.upperBound - yDomain.lowerBound)
Теперь мы можем применить эти диапазоны и вычисленное соотношение сторон к графику, используя PointPlot для отображения центров ячеек с размером символов, соответствующим частоте землетрясений.
Chart {
PointPlot(
data.cells,
x: .value("Longitude", \HexCell.longitude),
y: .value("Latitude", \HexCell.latitude)
)
.symbolSize(
by: .value("Earthquake frequency", \HexCell.earthquakeCount)
)
.foregroundStyle(.indigo)
}
.chartSymbolSizeScale(
domain: 0...data.maximumEarthquakeCount,
range: 10...200
)
.chartXScale(domain: xDomain, range: .plotDimension(padding: 0))
.chartYScale(domain: yDomain, range: .plotDimension(padding: 0))
.aspectRatio(aspectRatio, contentMode: .fit)
Модификатор symbolSize(by:) отображает частоту через шкалу размера символов графика. Её range описывает воспринимаемую площадь в квадратных points, а не диаметр. Ненулевой минимум сохраняет видимыми ячейки с низкой частотой, поэтому их площади не строго пропорциональны значениям.

Круги показывают, как частота землетрясений меняется по сетке со смещёнными рядами. Более крупные символы выделяют наиболее активные области, но их округлая форма не показывает сами границы ячеек.
Заполняем сетку шестиугольными символами
Чтобы заполнить каждую ячейку, можно заменить круги собственным символом, соответствующим ChartSymbolShape, определив шестиугольный path внутри переданных границ рисования.
struct Hexagon: ChartSymbolShape {
func path(in rect: CGRect) -> Path {
let points = [
CGPoint(x: rect.midX, y: rect.minY),
CGPoint(
x: rect.maxX,
y: rect.minY + rect.height * 0.25
),
CGPoint(
x: rect.maxX,
y: rect.minY + rect.height * 0.75
),
CGPoint(x: rect.midX, y: rect.maxY),
CGPoint(
x: rect.minX,
y: rect.minY + rect.height * 0.75
),
CGPoint(
x: rect.minX,
y: rect.minY + rect.height * 0.25
)
]
var path = Path()
path.addLines(points)
path.closeSubpath()
return path
}
}
Верхняя и нижняя вершины находятся в горизонтальном центре, а остальные четыре — на левой и правой границах на расстоянии четверти высоты от соответствующего края. Замыкание path позволяет Swift Charts заполнить полученную фигуру.
Чтобы шестиугольники состыковались друг с другом, мы можем задать всем символам одинаковый размер, рассчитанный на основе радиуса ячейки и ширины нашей области построения.
enum HeatMapStyle {
static func symbolArea(
in size: CGSize,
data: PreparedEarthquakeMapData
) -> CGFloat {
let renderedWidth = size.width
/ (data.xDomain.upperBound - data.xDomain.lowerBound)
* data.hexRadius * sqrt(3)
return max(18, renderedWidth * renderedWidth * 0.92)
}
}
Деление ширины области построения на ширину диапазона даёт количество экранных точек на один градус. Умножив это значение на ширину ячейки, мы получим ширину символа в экранном пространстве, которую затем можно возвести в квадрат и скорректировать коэффициентом 0.92, чтобы оставить небольшой визуальный зазор. Минимальная площадь в 18 квадратных точек сохраняет читаемость; при очень маленьком размере графика символы могут начать перекрываться вместо того, чтобы продолжать уменьшаться.
Chart {
PointPlot(
cells,
x: .value("Grid x", \.longitude),
y: .value("Grid y", \.latitude)
)
.symbolSize(regularSymbolArea)
.foregroundStyle(.indigo)
.symbol(Hexagon())
}
Мы можем применить вычисленную площадь через symbolSize, а затем заменить круги нашим кастомным шестиугольным символом. Сохраняя те же центры ячеек, мы увидим, как шестиугольники укладываются в сетку.

У символов шесть вершин, но строки со смещением не стыкуются равномерно, потому что каждый шестиугольник слишком широкий относительно своей высоты. У правильного шестиугольника в такой ориентации ширина равна sqrt(3) / 2 от высоты, то есть примерно 0.866.
Чтобы понять, почему Swift Charts рисует символы именно так, можно обвести область рисования прямоугольником с той же позицией и площадью символа.
struct SymbolDrawingFrame: ChartSymbolShape {
func path(in rect: CGRect) -> Path {
Path(rect).strokedPath(
StrokeStyle(lineWidth: 1.5, dash: [4, 3])
)
}
}
Chart {
// ... Existing hexagon plot ...
PointPlot(
cells,
x: .value("Grid x", \.longitude),
y: .value("Grid y", \.latitude)
)
.symbolSize(regularSymbolArea)
.foregroundStyle(.orange)
.symbol(SymbolDrawingFrame())
}
Наш SymbolDrawingFrame рисует пунктирный прямоугольник вокруг переданных границ. Отображая его в тех же позициях и с тем же размером символа, что и шестиугольники, мы можем сравнить фигуры с их реальными границами рисования.

Квадратные оранжевые рамки показывают, почему символы выглядят слишком широкими: наш path растягивает все шесть вершин по прямоугольнику, у которого ширина равна высоте.
Поскольку path размещает каждую вершину относительно переданного прямоугольника, мы можем исправить пропорции через perceptual frame, не меняя сам код рисования.
struct Hexagon: ChartSymbolShape {
var perceptualUnitRect: CGRect {
CGRect(
x: 0.067,
y: 0,
width: 0.866,
height: 1
)
}
// ... Existing path(in:) implementation ...
}
Более узкий и центрированный по горизонтали perceptualUnitRect сообщает Swift Charts нужные пропорции. При этом path продолжает использовать переданный прямоугольник, позволяя системе расчёта размера символов учитывать эту рамку.

Исправленные пропорции позволяют соседним рядам правильно соединяться друг с другом, оставляя между символами узкие зазоры.
Отображение частоты землетрясений через цвет
Размер наших символов определяется расстоянием между ячейками сетки, поэтому частоту землетрясений можно показывать цветом через foregroundStyle(by:).
struct HexCellsPlot: ChartContent {
let cells: [HexCell]
var body: some ChartContent {
PointPlot(
cells,
x: .value("Longitude", \HexCell.longitude),
y: .value("Latitude", \HexCell.latitude)
)
.foregroundStyle(
by: .value("Earthquake frequency", \HexCell.earthquakeCount)
)
.symbol(Hexagon())
}
}
Передача частоты в foregroundStyle(by:) позволяет шкале графика определять цвет каждой ячейки. Для начала можно использовать непрерывный градиент, линейно отображаемый от нуля до максимальной частоты.
Chart {
HexCellsPlot(cells: data.cells)
.symbolSize(regularSymbolArea)
}
.chartForegroundStyleScale(
domain: 0...data.maximumEarthquakeCount,
range: Gradient(colors: [
Color(red: 0.11, green: 0.22, blue: 0.34).opacity(0.12),
Color(red: 0.10, green: 0.55, blue: 0.58),
Color(red: 0.98, green: 0.72, blue: 0.24),
Color(red: 0.88, green: 0.22, blue: 0.18)
]),
type: .linear
)
Небольшая прозрачность возле нулевого значения позволяет ячейкам с низкой частотой землетрясений уходить на задний план, делая области с более высокой частотой заметнее.

Большинство ячеек оказывается у самого бледного края линейного градиента, из-за чего различия в частоте землетрясений трудно заметить. Шкала symmetricLog может показать больше различий среди низких значений, сжимая высокие, при этом сохраняя поддержку нуля. Хотя все занятые ячейки в нашем случае имеют частоту выше нуля, другие агрегированные метрики могут включать и нулевые значения.
Chart {
// ... Existing hexagon plot with uniform symbol size ...
}
.chartForegroundStyleScale(
domain: 0...data.maximumEarthquakeCount,
range: HeatMapStyle.frequencyGradient,
type: .symmetricLog(slopeAtZero: 1)
)
Симметрично-логарифмическую шкалу можно выбрать, установив параметр type у chartForegroundStyleScale(domain:range:type:) в .symmetricLog(slopeAtZero: 1). Параметр slopeAtZero управляет тем, насколько резко шкала меняется около нуля, позволяя регулировать, какая часть цветового диапазона используется для различения низких частот.

Симметрично-логарифмическая карта показывает больше вариаций среди ячеек с низкой частотой, сохраняя наиболее высокие значения в тёплой части палитры, хотя равные интервалы цвета больше не соответствуют равным различиям в частоте. Контур береговой линии добавляет географический контекст к итоговому графику; координаты из GeoJSON отображаются с помощью LinePlot. Если не заливать сушу цветом, шестиугольные ячейки остаются видимыми по обе стороны береговой линии.

Полный пример кода можно найти здесь, включая наложение береговой линии и view modifier, который адаптирует размер шестиугольников при изменении размера графика.
Если вы хотите глубже разобраться в Swift Charts и научиться анализировать свои данные и превращать их в красивые, производительные и доступные графики, обратите внимание на нашу новую книгу Swift Charts Beyond the Basics. Это насыщенный практический справочник по созданию продвинутых визуализаций данных с помощью этого фреймворка.
Другие материалы о Swift и SwiftUI можно найти в наших остальных книгах и наборах книг.
-
Разработка5 дней назадДаем ИИ видеть SwiftUI: Xcode Preview MCP на практике — проблемы и надежды
-
Новости2 недели назадВидео и подкасты о мобильной разработке 2026.39
-
Новости3 недели назадВидео и подкасты о мобильной разработке 2026.38
-
Новости4 дня назадВидео и подкасты о мобильной разработке 2026.40
