Add heat maps to SwiftUI's Map view with just a few lines of code:
Map {
HeatMapLayer(contours: contours, style: style)
}
.task {
let config = HeatMapConfiguration.adaptive(for: points)
contours = try? await HeatMapContours.compute(from: points, configuration: config)
}HeatMap is a native SwiftUI MapContent component β no image overlays, no UIKit bridging, no tile servers. It renders vector contour polygons directly inside Map, so you get smooth scaling, hit testing, and full integration with the MapKit camera, gestures, and annotations you already use.
- Drop-in
MapContentβHeatMapLayerworks like any otherMapcontent. No view representables, no coordinate conversions, no z-ordering hacks. - Works out of the box β
HeatMapConfiguration.adaptive(for:)inspects your data and picks a sensible radius and resolution automatically. Get a meaningful map before you've tuned anything. - Async and cancellation-aware β compute contours off the main thread with
async/await. Switching configurations or navigating away cancels stale work automatically. - Multiple render modes β filled polygons, contour isolines, or both together. Four built-in color gradients plus a
HeatMapGradient(colors:)API for your own. - Fully configurable β kernel radius, contour levels, grid resolution, level spacing (auto, linear, logarithmic, quantile, or custom thresholds), and polygon smoothing are all adjustable. Visual styling (gradient, fill opacity, render mode) is separate from computation configuration, so changing the look doesn't trigger a recompute.
- Built-in legend β
HeatMapLegendrenders the color scale with configurable orientation, label visibility, and custom endpoint text. Localization-ready out of the box. - Hit testing β query which contour levels contain a given coordinate, for tap-to-inspect interactions.
- Scales from city blocks to continents β the same configuration API works whether your data spans a neighborhood or a country.
- iOS 17+ / macOS 14+ / visionOS 1+
- Swift 6.0+
- Xcode 16+
Add HeatMap as a Swift Package Manager dependency:
- Open your project in Xcode.
- Go to File β Add Package Dependenciesβ¦
- Enter the repository URL:
https://github.com/tomhoag/HeatMap.git - Choose your version rule and add the package.
dependencies: [
.package(url: "https://github.com/tomhoag/HeatMap.git", from: "1.0.0")
]Then add "HeatMap" to the target's dependencies:
.target(
name: "YourTarget",
dependencies: ["HeatMap"]
)Your data model must conform to HeatMapable, which requires coordinate and weight properties:
import CoreLocation
import HeatMap
struct SensorReading: HeatMapable {
let id = UUID()
let coordinate: CLLocationCoordinate2D
let weight: Double
}weight must be non-negative. Higher values contribute more to the density field; a weight of 0 makes the point invisible.
Compute contours and pass them to HeatMapLayer. The async variant is recommended for large datasets to avoid blocking the UI; a synchronous overload is also available for smaller datasets or background contexts:
import HeatMap
import MapKit
import SwiftUI
struct MyMapView: View {
let points: [SensorReading]
@State private var contours: HeatMapContours?
var body: some View {
Map {
if let contours {
HeatMapLayer(contours: contours)
}
}
.task {
contours = try? await HeatMapContours.compute(from: points)
}
}
}Computation parameters live in HeatMapConfiguration; visual styling lives in HeatMapStyle. Changing a style property triggers an instant re-render without recomputing contours.
let config = HeatMapConfiguration(
radius: 1000, // Gaussian kernel radius in meters
contourLevels: 12, // number of contour bands
gridResolution: 120, // grid cells along the longer axis
smoother: .chaikin(iterations: 2) // polygon smoothing (default)
)
let style = HeatMapStyle(
gradient: .cool, // color gradient (.thermal, .warm, .cool, or custom)
fillOpacity: 0.8, // fill opacity (0β1)
renderMode: .filled // .filled, .isolines, or .filledWithIsolines
)
contours = try? await HeatMapContours.compute(from: points, configuration: config)| Parameter | Default | Description |
|---|---|---|
radius |
500 |
Gaussian kernel radius in meters. Larger values produce smoother, more diffuse maps. |
contourLevels |
10 |
Number of contour bands. More levels produce a finer gradient. |
levelSpacing |
.auto |
Threshold spacing strategy (.auto, .linear, .logarithmic, .quantile, or .custom([Double])). |
gridResolution |
100 |
Grid cells along the longer axis. Higher values increase detail and computation time. |
paddingFactor |
1.5 |
Bounding box padding as a multiple of radius. |
smoother |
.chaikin() |
Polygon smoother to reduce stair-step artifacts. |
| Parameter | Default | Description |
|---|---|---|
gradient |
.thermal |
Color gradient for mapping density to color. |
fillOpacity |
1.0 |
Fill opacity for contour polygons (0β1). |
renderMode |
.filled |
Contour rendering mode (.filled, .isolines(lineWidth:color:), or .filledWithIsolines(lineWidth:color:)). |
If you don't know the geographic scale of your data in advance, let the library pick a reasonable radius and gridResolution for you:
let config = HeatMapConfiguration.adaptive(for: points)
contours = try? await HeatMapContours.compute(from: points, configuration: config)You can still override individual properties afterward:
var config = HeatMapConfiguration.adaptive(for: points)
config.contourLevels = 15Note: The adaptive configuration is a snapshot of the current point set. If points change dynamically, you must call adaptive(for:) again, which may shift the radius or resolution and cause a visual discontinuity. For stable visuals with dynamic data, prefer setting configuration values explicitly.
The levelSpacing parameter controls how density thresholds are distributed between the grid's minimum and maximum values. Choosing the right strategy depends on your data distribution:
Inspects the computed density grid and automatically selects linear or quantile spacing based on how skewed the distribution is. When the mean-to-median ratio of non-zero density values exceeds 2 (indicating high-density peaks pulling the average well above typical values), quantile spacing is used. Otherwise, linear spacing is used.
let config = HeatMapConfiguration(levelSpacing: .auto)Use when: you don't know the characteristics of your data in advance, or you want reasonable results across a variety of datasets without manual tuning. This is the default.
Thresholds are evenly spaced across the density range. Best for data where points are distributed relatively uniformly and you want each contour band to represent the same density difference:
let config = HeatMapConfiguration(levelSpacing: .linear)Use when: point density is fairly uniform, or you want a perceptually linear mapping between color and density (e.g. a tight sensor grid, evenly distributed samples).
Concentrates more contour levels in the lower-density region while still covering the full range. Best for data with long-tail distributions where most of the variation occurs at lower values:
let config = HeatMapConfiguration(levelSpacing: .logarithmic)Use when: your data has a wide dynamic range but most detail is in the lower densities (e.g. population density near a city center, precipitation data, seismic activity).
Places thresholds at equal-area percentiles of the actual density distribution rather than dividing the range arithmetically. This guarantees contours appear even in sparse regions where density values are far below the global maximum:
let config = HeatMapConfiguration(levelSpacing: .quantile)Use when: your data has highly uneven spatial density β dense clusters in some areas and sparse coverage in others (e.g. weather station networks where coastal cities have many stations but rural interiors have few, cell tower maps with urban/rural contrast, species observation data with sampling bias).
Trade-off: because thresholds adapt to the data distribution, the density difference between adjacent contour bands is not constant. A band in a dense area may span a much larger density range than a band in a sparse area. The map will look more "filled in" but the visual uniformity can overstate the similarity between regions of very different density.
Provide explicit threshold values for full control. Values outside the computed density range are automatically filtered out:
let config = HeatMapConfiguration(levelSpacing: .custom([0.1, 0.5, 1.0, 5.0, 10.0]))Use when: you know the density values that matter for your domain and want exact control over where contour boundaries fall.
By default contours are rendered as filled polygons:
You can switch to contour lines (isolines), or combine filled polygons with an isoline overlay:
// Contour lines only, colored by gradient
let style = HeatMapStyle(renderMode: .isolines(lineWidth: 2))
// Uniform black isolines
let style = HeatMapStyle(renderMode: .isolines(lineWidth: 1, color: .black))
// Filled polygons with white isoline overlay
let style = HeatMapStyle(renderMode: .filledWithIsolines(color: .white))When color is nil (the default), each isoline is colored by the configured gradient at its contour level.
Use .task(id:) to recompute contours whenever the computation configuration changes. Style changes trigger a re-render automatically without recomputation:
@State private var contours: HeatMapContours?
@State private var config = HeatMapConfiguration()
@State private var style = HeatMapStyle()
var body: some View {
Map {
if let contours {
HeatMapLayer(contours: contours, style: style)
}
}
.task(id: config) {
contours = try? await HeatMapContours.compute(from: points, configuration: config)
}
}Because HeatMapStyle properties (gradient, fill opacity, render mode) are not part of HeatMapConfiguration, changing them does not trigger .task(id: config). SwiftUI re-evaluates the body and HeatMapLayer renders with the new style instantly.
The async compute method supports cooperative task cancellation. It checks for cancellation at natural checkpoints throughout the pipeline β during density grid computation, between contour levels, between polygon smoothing passes, and during annular assembly. If the task is cancelled, the method throws CancellationError and returns early.
SwiftUI's .task(id:) modifier automatically cancels the previous task when the id value changes, so adjusting a slider or switching datasets cancels any in-flight computation before starting a new one. Using try? silently discards the CancellationError and keeps the previous contours on screen until the new computation finishes.
The synchronous overload (compute(from:configuration:) -> HeatMapContours) shares the same pipeline internally. The cancellation checks are no-ops outside of a Task context, so the synchronous overload behaves identically to before while the async variant gets full cancellation support with no code duplication.
The computed contours expose their underlying polygon data for export or custom visualizations:
let result = try await HeatMapContours.compute(from: points)
for contour in result.contours {
print("Level \(contour.level), threshold \(contour.threshold): \(contour.coordinates.count) vertices")
}You can also hit-test a coordinate against the contours to find which levels contain it:
let hits = result.contours(containing: coordinate)Display a legend showing the color scale alongside the map:
Map {
if let contours {
HeatMapLayer(contours: contours, style: style)
}
}
.overlay(alignment: .bottomLeading) {
HeatMapLegend(gradient: style.gradient, levelCount: config.contourLevels)
.padding()
}For threshold labels derived from computed contours:
HeatMapLegend(contours: computedContours)Configure the axis and label visibility with modifiers:
HeatMapLegend(gradient: .thermal, levelCount: 10)
.axis(.horizontal)
.labels(.hidden)Force "Low" and "High" labels even when threshold data is available:
HeatMapLegend(contours: computedContours)
.labels(.lowHigh)Use custom endpoint labels:
HeatMapLegend(gradient: .thermal, levelCount: 10)
.labels(.customLowHigh(low: "Cold", high: "Hot"))Override the label color for better contrast against dark or light map backgrounds:
HeatMapLegend(gradient: .thermal, levelCount: 10)
.labelColor(.white)When using .isolines with a uniform color (e.g. .black or .white), every contour line looks identical regardless of its level, so the gradient legend provides no useful information and should be hidden. When color is nil (the default), each isoline is colored by the gradient and the legend remains meaningful.
For .filled and .filledWithIsolines modes the legend is always appropriate because the filled polygons carry gradient color information.
// Hide the legend when isolines use a uniform color
var showLegend: Bool {
switch style.renderMode {
case .isolines(_, let color):
return color == nil // gradient-colored β show; uniform color β hide
case .filled, .filledWithIsolines:
return true
}
}| Gradient | Colors |
|---|---|
.thermal |
transparent β blue β cyan β green β yellow β orange β red |
.warm |
transparent β yellow β orange β red |
.cool |
transparent β cyan β blue β purple |
.monochrome(color) |
transparent β color in six opacity steps |
Create a custom gradient with HeatMapGradient(colors:) (requires at least two colors):
let custom = HeatMapGradient(colors: [
.clear,
.blue.opacity(0.3),
.green.opacity(0.6),
.red
])The repository includes two example apps in HeatMapExample/HeatMap.xcodeproj. Both targets reference the local HeatMap package β open the project in Xcode, choose a scheme, and run.
A minimal integration showing the least code needed to get a heat map on screen β no control panels, no file loading. Start here.
A full-featured demo that ships with four CONUS weather event datasets (2021 Texas Freeze, 2021 PNW Heat Dome, 2024 Polar Vortex, 2024 Spring Front). A control panel (tap the Controls button) lets you switch datasets and adjust the radius, contour levels, color gradient, fill opacity, render mode, isoline color, and smoothing in real time.
Full API documentation is available at Swift Package Index.
See LICENSE for details.


