{"article":{"slug":"visualising-data-with-a-hexagonal-heatmap-in-swift-charts","title":"Visualising data with a hexagonal heatmap in Swift Charts","subtitle":null,"summary":"Explore alternatives to rectangular heatmap cells for visualising spatial data along coastlines and irregular boundaries.","content_type":"tutorial","language":"en","canonical_url":"https://nilcoalescing.com/blog/VisualisingDataWithAHexagonalHeatmapInSwiftCharts/","author":{"name":"Natalia Panferova","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"Nil Coalescing","url":"https://nilcoalescing.com","listing_slug":null,"listing":null},"topics":[{"name":"Swift","slug":"swift","url":"https://listedarticles.com/topics/swift"},{"name":"SwiftUI","slug":"swiftui","url":"https://listedarticles.com/topics/swiftui"},{"name":"Data Visualization","slug":"data-visualization","url":"https://listedarticles.com/topics/data-visualization"},{"name":"iOS","slug":"ios","url":"https://listedarticles.com/topics/ios"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":2015,"reading_minutes":9,"published_at":"2026-09-27T12:00:00.000Z","added_at":"2026-09-28T12:16:25.641Z","updated_at":"2026-09-28T12:16:25.641Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":false},"profile_url":"https://listedarticles.com/articles/visualising-data-with-a-hexagonal-heatmap-in-swift-charts","markdown_url":"https://listedarticles.com/articles/visualising-data-with-a-hexagonal-heatmap-in-swift-charts.md","example":false,"citation":"Natalia Panferova, Nil Coalescing. \"Visualising data with a hexagonal heatmap in Swift Charts.\" 27 Sept 2026. https://nilcoalescing.com/blog/VisualisingDataWithAHexagonalHeatmapInSwiftCharts/ (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://nilcoalescing.com/blog/VisualisingDataWithAHexagonalHeatmapInSwiftCharts/"},"body_markdown":"# Visualising data with a hexagonal heatmap in Swift Charts\n\nWhen creating charts of spatial data, readings are commonly aggregated into approximately equal-area cells to form a heatmap. In this post, we will use Swift Charts to plot earthquakes across Aotearoa New Zealand with hexagonal cells.\n\nA hexagonal grid is not only visually striking, but can also approximate the contours of land and political borders more naturally than a rectangular grid. Here, earthquake frequency determines each cell’s colour on a symmetric logarithmic scale.\n\n## \n     [#](https://nilcoalescing.com#arranging-points-in-a-hexagonal-grid)\nArranging points in a hexagonal grid\n\nA typical heatmap uses rectangle marks arranged in rows and columns. Hexagonal cells form staggered rows, which we can describe using axial coordinates to identify each cell with two integers, `q` and `r`. A point at each calculated centre can then establish our grid.\n\n```\nstruct HexCell: Identifiable, Sendable {\n    struct ID: Hashable, Sendable {\n        let q: Int\n        let r: Int\n    }\n    let id: ID\n    let longitude: Double\n    let latitude: Double\n    let earthquakes: [Earthquake]\n}\nenum HexGrid {\n    static func center(\n        for id: HexCell.ID,\n        radius: Double\n    ) -> (x: Double, y: Double) {\n        (\n            x: radius * sqrt(3) * (Double(id.q) + Double(id.r) / 2),\n            y: radius * 1.5 * Double(id.r)\n        )\n    }\n}\n```\nEach step along `q` moves one cell horizontally, while each step along `r` moves to the next row with a horizontal offset of half a cell. Together, the two coordinates give each cell’s centre, with the radius setting the spacing so neighbouring hexagons fit together. We can illustrate the arrangement with a centre cell and its six neighbours, using a radius of one.\n\n```\nlet gridIDs = [\n    HexCell.ID(q: 0, r: 0), HexCell.ID(q: 1, r: 0),\n    HexCell.ID(q: 0, r: 1), HexCell.ID(q: -1, r: 1),\n    HexCell.ID(q: -1, r: 0), HexCell.ID(q: 0, r: -1),\n    HexCell.ID(q: 1, r: -1)\n]\nlet gridCells = gridIDs.map { id in\n    let centre = HexGrid.center(for: id, radius: 1)\n    return HexCell(\n        id: id,\n        longitude: centre.x,\n        latitude: centre.y,\n        earthquakes: []\n    )\n}\nChart {\n    PointPlot(\n        gridCells,\n        x: .value(\"Grid x\", \\.longitude),\n        y: .value(\"Grid y\", \\.latitude)\n    )\n    .symbolSize(45)\n    .foregroundStyle(.indigo)\n}\n```\nThe default circular symbols in [PointPlot](https://developer.apple.com/documentation/charts/pointplot) allow us to see the grid before introducing the hexagonal shape, with coordinate labels identifying the seven integer pairs in the diagram.\n\nThe labels show how `q` increases from left to right within a row, while `r` increases from the lower row to the upper row. Following points with the same `q`, we can also see the half-cell horizontal shift that accompanies each step along `r`, giving the grid its staggered arrangement.\n\n## \n     [#](https://nilcoalescing.com#grouping-observations-into-cells)\nGrouping observations into cells\n\nThe sample earthquake data contains 53,763 observations from 2019–2024, prepared from the [GeoNet earthquake catalogue](https://www.geonet.org.nz/data/types/eq_catalogue). Grouping the observations into cells allows us to compare earthquake frequency across the region without overlapping points obscuring the distribution.\n\nEach loaded record supplies longitude, latitude, magnitude, and depth in kilometres.\n\n```\nstruct Earthquake: Identifiable, Sendable {\n    let id: String\n    let longitude: Double\n    let latitude: Double\n    let magnitude: Double\n    let depth: Double\n}\n```\nFor simplicity, we can work directly with longitude and latitude, giving our cells equal areas in coordinate space. Depending on the region, an equal-area projection may be more appropriate. Reversing our centre calculation then gives fractional `q` and `r` values, which we can round to identify the containing cell.\n\n```\nextension HexGrid {\n    static func cell(\n        x: Double,\n        y: Double,\n        radius: Double\n    ) -> HexCell.ID {\n        rounded(\n            q: (sqrt(3) / 3 * x - y / 3) / radius,\n            r: (2 * y / 3) / radius\n        )\n    }\n}\n```\nRounding `q` and `r` independently may place an observation in the wrong cell near an inclined edge. We can add a derived coordinate, `s = -q - r`, which constrains the three values to sum to zero. After rounding all three, adjusting the coordinate with the largest rounding error restores that constraint.\n\n```\nprivate extension HexGrid {\n    static func rounded(q: Double, r: Double) -> HexCell.ID {\n        let x = q\n        let z = r\n        let y = -x - z\n        var roundedX = x.rounded()\n        var roundedY = y.rounded()\n        var roundedZ = z.rounded()\n        let xDifference = abs(roundedX - x)\n        let yDifference = abs(roundedY - y)\n        let zDifference = abs(roundedZ - z)\n        if xDifference > yDifference,\n           xDifference > zDifference {\n            roundedX = -roundedY - roundedZ\n        } else if yDifference > zDifference {\n            roundedY = -roundedX - roundedZ\n        } else {\n            roundedZ = -roundedX - roundedY\n        }\n        return HexCell.ID(\n            q: Int(roundedX),\n            r: Int(roundedZ)\n        )\n    }\n}\n```\nThe correction restores the zero-sum constraint and returns an integer pair identifying the nearest hexagonal cell. With a consistent identifier for each position, we can now group the observations by cell using a dictionary.\n\n```\nlet grouped = Dictionary(grouping: earthquakes) { earthquake in\n    HexGrid.cell(\n        x: earthquake.longitude,\n        y: earthquake.latitude,\n        radius: hexRadius\n    )\n}\n```\nEach dictionary entry contains the earthquakes assigned to one cell, giving us a single centre to plot and a collection whose size determines its frequency.\n\n```\nlet cells = grouped.map { id, earthquakes in\n    let centre = HexGrid.center(for: id, radius: hexRadius)\n    return HexCell(\n        id: id,\n        longitude: centre.x,\n        latitude: centre.y,\n        earthquakes: earthquakes\n    )\n}\n```\nOnly cells containing observations appear in the dictionary, so empty areas require no points in the chart.\n\nAfter grouping, the value at each grid centre represents the earthquake frequency across the whole cell, rather than an observation at that position.\n\n## \n     [#](https://nilcoalescing.com#showing-earthquake-frequency-through-point-size)\nShowing earthquake frequency through point size\n\nThe aggregated cells can use the same point plot as our empty grid. Before introducing hexagonal symbols, we can vary the size of the circles so cells with a higher earthquake frequency appear larger.\n\n```\nextension HexCell {\n    var earthquakeCount: Int { earthquakes.count }\n}\n```\nThe longitude and latitude domains define the region displayed in our heatmap. Matching the chart’s aspect ratio to the ratio of those domain widths ensures our hexagons retain their proportions and fit neatly together.\n\n```\nlet xDomain = 164.0...180.0\nlet yDomain = -48.5...(-33.5)\nlet aspectRatio =\n    (xDomain.upperBound - xDomain.lowerBound)\n    / (yDomain.upperBound - yDomain.lowerBound)\n```\nWe can now apply these domains and the calculated aspect ratio to our chart, using `PointPlot` to show the cell centres with symbol sizes representing earthquake frequency.\n\n```\nChart {\n    PointPlot(\n        data.cells,\n        x: .value(\"Longitude\", \\HexCell.longitude),\n        y: .value(\"Latitude\", \\HexCell.latitude)\n    )\n    .symbolSize(\n        by: .value(\"Earthquake frequency\", \\HexCell.earthquakeCount)\n    )\n    .foregroundStyle(.indigo)\n}\n.chartSymbolSizeScale(\n    domain: 0...data.maximumEarthquakeCount,\n    range: 10...200\n)\n.chartXScale(domain: xDomain, range: .plotDimension(padding: 0))\n.chartYScale(domain: yDomain, range: .plotDimension(padding: 0))\n.aspectRatio(aspectRatio, contentMode: .fit)\n```\nThe [symbolSize(by:)](<https://developer.apple.com/documentation/charts/vectorizedchartcontent/symbolsize(by:)>) modifier maps frequency through the chart's symbol-size scale. Its range describes perceived areas in square points rather than diameters. A nonzero minimum keeps cells with a low frequency visible, so their areas are not strictly proportional to the values.\n\nThe circles show how earthquake frequency varies across the staggered grid. Larger symbols distinguish the busiest locations, while their round shapes leave the cells themselves undefined.\n\n## \n     [#](https://nilcoalescing.com#filling-the-grid-with-hexagonal-symbols)\nFilling the grid with hexagonal symbols\n\nTo fill each cell, we can replace the circles with a custom symbol conforming to [ChartSymbolShape](https://developer.apple.com/documentation/charts/chartsymbolshape), defining a six-sided path within the supplied drawing bounds.\n\n```\nstruct Hexagon: ChartSymbolShape {\n    func path(in rect: CGRect) -> Path {\n        let points = [\n            CGPoint(x: rect.midX, y: rect.minY),\n            CGPoint(\n                x: rect.maxX,\n                y: rect.minY + rect.height * 0.25\n            ),\n            CGPoint(\n                x: rect.maxX,\n                y: rect.minY + rect.height * 0.75\n            ),\n            CGPoint(x: rect.midX, y: rect.maxY),\n            CGPoint(\n                x: rect.minX,\n                y: rect.minY + rect.height * 0.75\n            ),\n            CGPoint(\n                x: rect.minX,\n                y: rect.minY + rect.height * 0.25\n            )\n        ]\n        var path = Path()\n        path.addLines(points)\n        path.closeSubpath()\n        return path\n    }\n}\n```\nThe top and bottom corners sit at the horizontal centre, with the remaining four on the left and right edges, a quarter of the height from either end. Closing the path allows Swift Charts to fill the resulting shape.\n\nTo fit the hexagons together, we can give every symbol the same size, calculated from the cell radius and the width of our plot.\n\n```\nenum HeatMapStyle {\n    static func symbolArea(\n        in size: CGSize,\n        data: PreparedEarthquakeMapData\n    ) -> CGFloat {\n        let renderedWidth = size.width\n            / (data.xDomain.upperBound - data.xDomain.lowerBound)\n            * data.hexRadius * sqrt(3)\n        return max(18, renderedWidth * renderedWidth * 0.92)\n    }\n}\n```\nDividing the plot width by the domain width gives screen points per degree. Multiplying that value by the cell width produces the symbol's screen-space width, which can then be squared and adjusted by a factor of `0.92` to leave a small visual separation. A minimum area of `18` square points preserves legibility; at very small chart sizes, symbols can overlap instead of continuing to shrink.\n\n```\nChart {\n    PointPlot(\n        cells,\n        x: .value(\"Grid x\", \\.longitude),\n        y: .value(\"Grid y\", \\.latitude)\n    )\n    .symbolSize(regularSymbolArea)\n    .foregroundStyle(.indigo)\n    .symbol(Hexagon())\n}\n```\nWe can apply the calculated area with `symbolSize`, then replace the circles with our custom hexagon symbol. Keeping the same cell centres allows us to see how the hexagons fit into the grid.\n\nThe symbols have six corners, but the staggered rows do not fit together evenly because each hexagon is too wide relative to its height. A regular hexagon in that orientation has a width of `sqrt(3) / 2` times its height, approximately `0.866`.\n\nTo see why Swift Charts draws the symbols this way, we can outline the drawing frame with a rectangle at the same position and symbol area.\n\n```\nstruct SymbolDrawingFrame: ChartSymbolShape {\n    func path(in rect: CGRect) -> Path {\n        Path(rect).strokedPath(\n            StrokeStyle(lineWidth: 1.5, dash: [4, 3])\n        )\n    }\n}\nChart {\n    // ... Existing hexagon plot ...\n    PointPlot(\n        cells,\n        x: .value(\"Grid x\", \\.longitude),\n        y: .value(\"Grid y\", \\.latitude)\n    )\n    .symbolSize(regularSymbolArea)\n    .foregroundStyle(.orange)\n    .symbol(SymbolDrawingFrame())\n}\n```\nOur `SymbolDrawingFrame` draws a dashed rectangle around the supplied bounds. By plotting it at the same positions and symbol size as our hexagons, we can compare the shapes with their drawing bounds.\n\nThe square orange frames reveal why the symbols look broad: our path spreads all six corners across a rectangle whose width equals its height.\n\nBecause the path positions each corner relative to the supplied rectangle, we can correct the proportions in the perceptual frame without changing our drawing code.\n\n```\nstruct Hexagon: ChartSymbolShape {\n    var perceptualUnitRect: CGRect {\n        CGRect(\n            x: 0.067,\n            y: 0,\n            width: 0.866,\n            height: 1\n        )\n    }\n    // ... Existing path(in:) implementation ...\n}\n```\nThe narrower, horizontally centred [perceptualUnitRect](https://developer.apple.com/documentation/charts/chartsymbolshape/perceptualunitrect) describes the intended proportions to Swift Charts. The path still uses the supplied rectangle, allowing the chart's symbol sizing to account for the frame.\n\nThe corrected proportions allow neighbouring rows to fit together, with narrow gaps separating the symbols.\n\n## \n     [#](https://nilcoalescing.com#mapping-earthquake-frequency-to-colour)\nMapping earthquake frequency to colour\n\nOur symbol size is determined by the grid spacing, so we can represent earthquake frequency through colour using `foregroundStyle(by:)`.\n\n```\nstruct HexCellsPlot: ChartContent {\n    let cells: [HexCell]\n    var body: some ChartContent {\n        PointPlot(\n            cells,\n            x: .value(\"Longitude\", \\HexCell.longitude),\n            y: .value(\"Latitude\", \\HexCell.latitude)\n        )\n        .foregroundStyle(\n            by: .value(\"Earthquake frequency\", \\HexCell.earthquakeCount)\n        )\n        .symbol(Hexagon())\n    }\n}\n```\nPassing frequency to `foregroundStyle(by:)` allows the chart’s scale to determine each cell’s colour. We can start with a continuous gradient mapped linearly from zero to the highest frequency.\n\n```\nChart {\n    HexCellsPlot(cells: data.cells)\n        .symbolSize(regularSymbolArea)\n}\n.chartForegroundStyleScale(\n    domain: 0...data.maximumEarthquakeCount,\n    range: Gradient(colors: [\n        Color(red: 0.11, green: 0.22, blue: 0.34).opacity(0.12),\n        Color(red: 0.10, green: 0.55, blue: 0.58),\n        Color(red: 0.98, green: 0.72, blue: 0.24),\n        Color(red: 0.88, green: 0.22, blue: 0.18)\n    ]),\n    type: .linear\n)\n```\nA little transparency near zero allows cells with lower earthquake frequencies to recede into the background, making areas of higher frequency easier to distinguish.\n\nMost cells sit near the faint end of the linear gradient, making differences in earthquake frequency difficult to see. A [`symmetricLog`](https://developer.apple.com/documentation/charts/scaletype/symmetriclog) scale can reveal more variation among the lower values by compressing the higher ones, while retaining support for zero. Although our occupied cells all have a frequency above zero, other aggregated metrics may include zero values.\n\n```\nChart {\n    // ... Existing hexagon plot with uniform symbol size ...\n}\n.chartForegroundStyleScale(\n    domain: 0...data.maximumEarthquakeCount,\n    range: HeatMapStyle.frequencyGradient,\n    type: .symmetricLog(slopeAtZero: 1)\n)\n```\nWe can select the [symmetric-log scale](https://developer.apple.com/documentation/charts/scaletype/symmetriclog) by setting the `type` parameter of `chartForegroundStyleScale(domain:range:type:)` to `.symmetricLog(slopeAtZero: 1)`. The `slopeAtZero` parameter controls how steeply the scale changes near zero, allowing us to adjust how much of the colour range is available to distinguish lower frequencies.\n\nThe symmetric-log map reveals more variation among cells with lower frequencies while retaining the highest frequencies at the warm end, although equal colour intervals no longer represent equal differences in frequency. A coastline outline adds geographic context to the final chart, with coordinates from GeoJSON drawn using a `LinePlot`. Leaving the land unfilled keeps the hexagonal cells visible on both sides of the shore.\n\nYou can find the full sample code [here](https://gist.github.com/hishnash/2ffc9fca47f80f41684361da02435556), including the coastline overlay and a view modifier that adapts the hexagon size as the chart resizes.\n\nIf you are looking to deepen your understanding of Swift Charts and learn how to reason about your data and turn it into beautiful, performant, and accessible charts, take a look at our new book [Swift Charts Beyond the Basics](https://books.nilcoalescing.com/swift-charts-beyond-the-basics?utm_source=nilcoalescing&utm_medium=blog&utm_term=VisualisingDataWithAHexagonalHeatmapInSwiftCharts&utm_content=inline). It is a rich, practical reference for building advanced data visualizations with the framework.\n\nFor more resources on Swift and SwiftUI, check out our other [books](https://books.nilcoalescing.com?utm_source=nilcoalescing&utm_medium=blog&utm_term=VisualisingDataWithAHexagonalHeatmapInSwiftCharts&utm_content=inline) and [book bundles](https://books.nilcoalescing.com/bundles?utm_source=nilcoalescing&utm_medium=blog&utm_term=VisualisingDataWithAHexagonalHeatmapInSwiftCharts&utm_content=inline).","body_html":"<h1 id=\"visualising-data-with-a-hexagonal-heatmap-in-swift-charts\">Visualising data with a hexagonal heatmap in Swift Charts</h1>\n<p>When creating charts of spatial data, readings are commonly aggregated into approximately equal-area cells to form a heatmap. In this post, we will use Swift Charts to plot earthquakes across Aotearoa New Zealand with hexagonal cells.</p>\n<p>A hexagonal grid is not only visually striking, but can also approximate the contours of land and political borders more naturally than a rectangular grid. Here, earthquake frequency determines each cell’s colour on a symmetric logarithmic scale.</p>\n<p>## \n     <a href=\"https://nilcoalescing.com#arranging-points-in-a-hexagonal-grid\" rel=\"nofollow ugc noopener\">#</a>\nArranging points in a hexagonal grid</p>\n<p>A typical heatmap uses rectangle marks arranged in rows and columns. Hexagonal cells form staggered rows, which we can describe using axial coordinates to identify each cell with two integers, <code>q</code> and <code>r</code>. A point at each calculated centre can then establish our grid.</p>\n<pre><code>struct HexCell: Identifiable, Sendable {\n    struct ID: Hashable, Sendable {\n        let q: Int\n        let r: Int\n    }\n    let id: ID\n    let longitude: Double\n    let latitude: Double\n    let earthquakes: [Earthquake]\n}\nenum HexGrid {\n    static func center(\n        for id: HexCell.ID,\n        radius: Double\n    ) -&gt; (x: Double, y: Double) {\n        (\n            x: radius * sqrt(3) * (Double(id.q) + Double(id.r) / 2),\n            y: radius * 1.5 * Double(id.r)\n        )\n    }\n}</code></pre>\n<p>Each step along <code>q</code> moves one cell horizontally, while each step along <code>r</code> moves to the next row with a horizontal offset of half a cell. Together, the two coordinates give each cell’s centre, with the radius setting the spacing so neighbouring hexagons fit together. We can illustrate the arrangement with a centre cell and its six neighbours, using a radius of one.</p>\n<pre><code>let gridIDs = [\n    HexCell.ID(q: 0, r: 0), HexCell.ID(q: 1, r: 0),\n    HexCell.ID(q: 0, r: 1), HexCell.ID(q: -1, r: 1),\n    HexCell.ID(q: -1, r: 0), HexCell.ID(q: 0, r: -1),\n    HexCell.ID(q: 1, r: -1)\n]\nlet gridCells = gridIDs.map { id in\n    let centre = HexGrid.center(for: id, radius: 1)\n    return HexCell(\n        id: id,\n        longitude: centre.x,\n        latitude: centre.y,\n        earthquakes: []\n    )\n}\nChart {\n    PointPlot(\n        gridCells,\n        x: .value(&quot;Grid x&quot;, \\.longitude),\n        y: .value(&quot;Grid y&quot;, \\.latitude)\n    )\n    .symbolSize(45)\n    .foregroundStyle(.indigo)\n}</code></pre>\n<p>The default circular symbols in <a href=\"https://developer.apple.com/documentation/charts/pointplot\" rel=\"nofollow ugc noopener\">PointPlot</a> allow us to see the grid before introducing the hexagonal shape, with coordinate labels identifying the seven integer pairs in the diagram.</p>\n<p>The labels show how <code>q</code> increases from left to right within a row, while <code>r</code> increases from the lower row to the upper row. Following points with the same <code>q</code>, we can also see the half-cell horizontal shift that accompanies each step along <code>r</code>, giving the grid its staggered arrangement.</p>\n<p>## \n     <a href=\"https://nilcoalescing.com#grouping-observations-into-cells\" rel=\"nofollow ugc noopener\">#</a>\nGrouping observations into cells</p>\n<p>The sample earthquake data contains 53,763 observations from 2019–2024, prepared from the <a href=\"https://www.geonet.org.nz/data/types/eq_catalogue\" rel=\"nofollow ugc noopener\">GeoNet earthquake catalogue</a>. Grouping the observations into cells allows us to compare earthquake frequency across the region without overlapping points obscuring the distribution.</p>\n<p>Each loaded record supplies longitude, latitude, magnitude, and depth in kilometres.</p>\n<pre><code>struct Earthquake: Identifiable, Sendable {\n    let id: String\n    let longitude: Double\n    let latitude: Double\n    let magnitude: Double\n    let depth: Double\n}</code></pre>\n<p>For simplicity, we can work directly with longitude and latitude, giving our cells equal areas in coordinate space. Depending on the region, an equal-area projection may be more appropriate. Reversing our centre calculation then gives fractional <code>q</code> and <code>r</code> values, which we can round to identify the containing cell.</p>\n<pre><code>extension HexGrid {\n    static func cell(\n        x: Double,\n        y: Double,\n        radius: Double\n    ) -&gt; HexCell.ID {\n        rounded(\n            q: (sqrt(3) / 3 * x - y / 3) / radius,\n            r: (2 * y / 3) / radius\n        )\n    }\n}</code></pre>\n<p>Rounding <code>q</code> and <code>r</code> independently may place an observation in the wrong cell near an inclined edge. We can add a derived coordinate, <code>s = -q - r</code>, which constrains the three values to sum to zero. After rounding all three, adjusting the coordinate with the largest rounding error restores that constraint.</p>\n<pre><code>private extension HexGrid {\n    static func rounded(q: Double, r: Double) -&gt; HexCell.ID {\n        let x = q\n        let z = r\n        let y = -x - z\n        var roundedX = x.rounded()\n        var roundedY = y.rounded()\n        var roundedZ = z.rounded()\n        let xDifference = abs(roundedX - x)\n        let yDifference = abs(roundedY - y)\n        let zDifference = abs(roundedZ - z)\n        if xDifference &gt; yDifference,\n           xDifference &gt; zDifference {\n            roundedX = -roundedY - roundedZ\n        } else if yDifference &gt; zDifference {\n            roundedY = -roundedX - roundedZ\n        } else {\n            roundedZ = -roundedX - roundedY\n        }\n        return HexCell.ID(\n            q: Int(roundedX),\n            r: Int(roundedZ)\n        )\n    }\n}</code></pre>\n<p>The correction restores the zero-sum constraint and returns an integer pair identifying the nearest hexagonal cell. With a consistent identifier for each position, we can now group the observations by cell using a dictionary.</p>\n<pre><code>let grouped = Dictionary(grouping: earthquakes) { earthquake in\n    HexGrid.cell(\n        x: earthquake.longitude,\n        y: earthquake.latitude,\n        radius: hexRadius\n    )\n}</code></pre>\n<p>Each dictionary entry contains the earthquakes assigned to one cell, giving us a single centre to plot and a collection whose size determines its frequency.</p>\n<pre><code>let cells = grouped.map { id, earthquakes in\n    let centre = HexGrid.center(for: id, radius: hexRadius)\n    return HexCell(\n        id: id,\n        longitude: centre.x,\n        latitude: centre.y,\n        earthquakes: earthquakes\n    )\n}</code></pre>\n<p>Only cells containing observations appear in the dictionary, so empty areas require no points in the chart.</p>\n<p>After grouping, the value at each grid centre represents the earthquake frequency across the whole cell, rather than an observation at that position.</p>\n<p>## \n     <a href=\"https://nilcoalescing.com#showing-earthquake-frequency-through-point-size\" rel=\"nofollow ugc noopener\">#</a>\nShowing earthquake frequency through point size</p>\n<p>The aggregated cells can use the same point plot as our empty grid. Before introducing hexagonal symbols, we can vary the size of the circles so cells with a higher earthquake frequency appear larger.</p>\n<pre><code>extension HexCell {\n    var earthquakeCount: Int { earthquakes.count }\n}</code></pre>\n<p>The longitude and latitude domains define the region displayed in our heatmap. Matching the chart’s aspect ratio to the ratio of those domain widths ensures our hexagons retain their proportions and fit neatly together.</p>\n<pre><code>let xDomain = 164.0...180.0\nlet yDomain = -48.5...(-33.5)\nlet aspectRatio =\n    (xDomain.upperBound - xDomain.lowerBound)\n    / (yDomain.upperBound - yDomain.lowerBound)</code></pre>\n<p>We can now apply these domains and the calculated aspect ratio to our chart, using <code>PointPlot</code> to show the cell centres with symbol sizes representing earthquake frequency.</p>\n<pre><code>Chart {\n    PointPlot(\n        data.cells,\n        x: .value(&quot;Longitude&quot;, \\HexCell.longitude),\n        y: .value(&quot;Latitude&quot;, \\HexCell.latitude)\n    )\n    .symbolSize(\n        by: .value(&quot;Earthquake frequency&quot;, \\HexCell.earthquakeCount)\n    )\n    .foregroundStyle(.indigo)\n}\n.chartSymbolSizeScale(\n    domain: 0...data.maximumEarthquakeCount,\n    range: 10...200\n)\n.chartXScale(domain: xDomain, range: .plotDimension(padding: 0))\n.chartYScale(domain: yDomain, range: .plotDimension(padding: 0))\n.aspectRatio(aspectRatio, contentMode: .fit)</code></pre>\n<p>The <a href=\"https://developer.apple.com/documentation/charts/vectorizedchartcontent/symbolsize(by:)\" rel=\"nofollow ugc noopener\">symbolSize(by:)</a> modifier maps frequency through the chart&#39;s symbol-size scale. Its range describes perceived areas in square points rather than diameters. A nonzero minimum keeps cells with a low frequency visible, so their areas are not strictly proportional to the values.</p>\n<p>The circles show how earthquake frequency varies across the staggered grid. Larger symbols distinguish the busiest locations, while their round shapes leave the cells themselves undefined.</p>\n<p>## \n     <a href=\"https://nilcoalescing.com#filling-the-grid-with-hexagonal-symbols\" rel=\"nofollow ugc noopener\">#</a>\nFilling the grid with hexagonal symbols</p>\n<p>To fill each cell, we can replace the circles with a custom symbol conforming to <a href=\"https://developer.apple.com/documentation/charts/chartsymbolshape\" rel=\"nofollow ugc noopener\">ChartSymbolShape</a>, defining a six-sided path within the supplied drawing bounds.</p>\n<pre><code>struct Hexagon: ChartSymbolShape {\n    func path(in rect: CGRect) -&gt; Path {\n        let points = [\n            CGPoint(x: rect.midX, y: rect.minY),\n            CGPoint(\n                x: rect.maxX,\n                y: rect.minY + rect.height * 0.25\n            ),\n            CGPoint(\n                x: rect.maxX,\n                y: rect.minY + rect.height * 0.75\n            ),\n            CGPoint(x: rect.midX, y: rect.maxY),\n            CGPoint(\n                x: rect.minX,\n                y: rect.minY + rect.height * 0.75\n            ),\n            CGPoint(\n                x: rect.minX,\n                y: rect.minY + rect.height * 0.25\n            )\n        ]\n        var path = Path()\n        path.addLines(points)\n        path.closeSubpath()\n        return path\n    }\n}</code></pre>\n<p>The top and bottom corners sit at the horizontal centre, with the remaining four on the left and right edges, a quarter of the height from either end. Closing the path allows Swift Charts to fill the resulting shape.</p>\n<p>To fit the hexagons together, we can give every symbol the same size, calculated from the cell radius and the width of our plot.</p>\n<pre><code>enum HeatMapStyle {\n    static func symbolArea(\n        in size: CGSize,\n        data: PreparedEarthquakeMapData\n    ) -&gt; CGFloat {\n        let renderedWidth = size.width\n            / (data.xDomain.upperBound - data.xDomain.lowerBound)\n            * data.hexRadius * sqrt(3)\n        return max(18, renderedWidth * renderedWidth * 0.92)\n    }\n}</code></pre>\n<p>Dividing the plot width by the domain width gives screen points per degree. Multiplying that value by the cell width produces the symbol&#39;s screen-space width, which can then be squared and adjusted by a factor of <code>0.92</code> to leave a small visual separation. A minimum area of <code>18</code> square points preserves legibility; at very small chart sizes, symbols can overlap instead of continuing to shrink.</p>\n<pre><code>Chart {\n    PointPlot(\n        cells,\n        x: .value(&quot;Grid x&quot;, \\.longitude),\n        y: .value(&quot;Grid y&quot;, \\.latitude)\n    )\n    .symbolSize(regularSymbolArea)\n    .foregroundStyle(.indigo)\n    .symbol(Hexagon())\n}</code></pre>\n<p>We can apply the calculated area with <code>symbolSize</code>, then replace the circles with our custom hexagon symbol. Keeping the same cell centres allows us to see how the hexagons fit into the grid.</p>\n<p>The symbols have six corners, but the staggered rows do not fit together evenly because each hexagon is too wide relative to its height. A regular hexagon in that orientation has a width of <code>sqrt(3) / 2</code> times its height, approximately <code>0.866</code>.</p>\n<p>To see why Swift Charts draws the symbols this way, we can outline the drawing frame with a rectangle at the same position and symbol area.</p>\n<pre><code>struct SymbolDrawingFrame: ChartSymbolShape {\n    func path(in rect: CGRect) -&gt; Path {\n        Path(rect).strokedPath(\n            StrokeStyle(lineWidth: 1.5, dash: [4, 3])\n        )\n    }\n}\nChart {\n    // ... Existing hexagon plot ...\n    PointPlot(\n        cells,\n        x: .value(&quot;Grid x&quot;, \\.longitude),\n        y: .value(&quot;Grid y&quot;, \\.latitude)\n    )\n    .symbolSize(regularSymbolArea)\n    .foregroundStyle(.orange)\n    .symbol(SymbolDrawingFrame())\n}</code></pre>\n<p>Our <code>SymbolDrawingFrame</code> draws a dashed rectangle around the supplied bounds. By plotting it at the same positions and symbol size as our hexagons, we can compare the shapes with their drawing bounds.</p>\n<p>The square orange frames reveal why the symbols look broad: our path spreads all six corners across a rectangle whose width equals its height.</p>\n<p>Because the path positions each corner relative to the supplied rectangle, we can correct the proportions in the perceptual frame without changing our drawing code.</p>\n<pre><code>struct Hexagon: ChartSymbolShape {\n    var perceptualUnitRect: CGRect {\n        CGRect(\n            x: 0.067,\n            y: 0,\n            width: 0.866,\n            height: 1\n        )\n    }\n    // ... Existing path(in:) implementation ...\n}</code></pre>\n<p>The narrower, horizontally centred <a href=\"https://developer.apple.com/documentation/charts/chartsymbolshape/perceptualunitrect\" rel=\"nofollow ugc noopener\">perceptualUnitRect</a> describes the intended proportions to Swift Charts. The path still uses the supplied rectangle, allowing the chart&#39;s symbol sizing to account for the frame.</p>\n<p>The corrected proportions allow neighbouring rows to fit together, with narrow gaps separating the symbols.</p>\n<p>## \n     <a href=\"https://nilcoalescing.com#mapping-earthquake-frequency-to-colour\" rel=\"nofollow ugc noopener\">#</a>\nMapping earthquake frequency to colour</p>\n<p>Our symbol size is determined by the grid spacing, so we can represent earthquake frequency through colour using <code>foregroundStyle(by:)</code>.</p>\n<pre><code>struct HexCellsPlot: ChartContent {\n    let cells: [HexCell]\n    var body: some ChartContent {\n        PointPlot(\n            cells,\n            x: .value(&quot;Longitude&quot;, \\HexCell.longitude),\n            y: .value(&quot;Latitude&quot;, \\HexCell.latitude)\n        )\n        .foregroundStyle(\n            by: .value(&quot;Earthquake frequency&quot;, \\HexCell.earthquakeCount)\n        )\n        .symbol(Hexagon())\n    }\n}</code></pre>\n<p>Passing frequency to <code>foregroundStyle(by:)</code> allows the chart’s scale to determine each cell’s colour. We can start with a continuous gradient mapped linearly from zero to the highest frequency.</p>\n<pre><code>Chart {\n    HexCellsPlot(cells: data.cells)\n        .symbolSize(regularSymbolArea)\n}\n.chartForegroundStyleScale(\n    domain: 0...data.maximumEarthquakeCount,\n    range: Gradient(colors: [\n        Color(red: 0.11, green: 0.22, blue: 0.34).opacity(0.12),\n        Color(red: 0.10, green: 0.55, blue: 0.58),\n        Color(red: 0.98, green: 0.72, blue: 0.24),\n        Color(red: 0.88, green: 0.22, blue: 0.18)\n    ]),\n    type: .linear\n)</code></pre>\n<p>A little transparency near zero allows cells with lower earthquake frequencies to recede into the background, making areas of higher frequency easier to distinguish.</p>\n<p>Most cells sit near the faint end of the linear gradient, making differences in earthquake frequency difficult to see. A <a href=\"https://developer.apple.com/documentation/charts/scaletype/symmetriclog\" rel=\"nofollow ugc noopener\"><code>symmetricLog</code></a> scale can reveal more variation among the lower values by compressing the higher ones, while retaining support for zero. Although our occupied cells all have a frequency above zero, other aggregated metrics may include zero values.</p>\n<pre><code>Chart {\n    // ... Existing hexagon plot with uniform symbol size ...\n}\n.chartForegroundStyleScale(\n    domain: 0...data.maximumEarthquakeCount,\n    range: HeatMapStyle.frequencyGradient,\n    type: .symmetricLog(slopeAtZero: 1)\n)</code></pre>\n<p>We can select the <a href=\"https://developer.apple.com/documentation/charts/scaletype/symmetriclog\" rel=\"nofollow ugc noopener\">symmetric-log scale</a> by setting the <code>type</code> parameter of <code>chartForegroundStyleScale(domain:range:type:)</code> to <code>.symmetricLog(slopeAtZero: 1)</code>. The <code>slopeAtZero</code> parameter controls how steeply the scale changes near zero, allowing us to adjust how much of the colour range is available to distinguish lower frequencies.</p>\n<p>The symmetric-log map reveals more variation among cells with lower frequencies while retaining the highest frequencies at the warm end, although equal colour intervals no longer represent equal differences in frequency. A coastline outline adds geographic context to the final chart, with coordinates from GeoJSON drawn using a <code>LinePlot</code>. Leaving the land unfilled keeps the hexagonal cells visible on both sides of the shore.</p>\n<p>You can find the full sample code <a href=\"https://gist.github.com/hishnash/2ffc9fca47f80f41684361da02435556\" rel=\"nofollow ugc noopener\">here</a>, including the coastline overlay and a view modifier that adapts the hexagon size as the chart resizes.</p>\n<p>If you are looking to deepen your understanding of Swift Charts and learn how to reason about your data and turn it into beautiful, performant, and accessible charts, take a look at our new book <a href=\"https://books.nilcoalescing.com/swift-charts-beyond-the-basics?utm_source=nilcoalescing&amp;utm_medium=blog&amp;utm_term=VisualisingDataWithAHexagonalHeatmapInSwiftCharts&amp;utm_content=inline\" rel=\"nofollow ugc noopener\">Swift Charts Beyond the Basics</a>. It is a rich, practical reference for building advanced data visualizations with the framework.</p>\n<p>For more resources on Swift and SwiftUI, check out our other <a href=\"https://books.nilcoalescing.com?utm_source=nilcoalescing&amp;utm_medium=blog&amp;utm_term=VisualisingDataWithAHexagonalHeatmapInSwiftCharts&amp;utm_content=inline\" rel=\"nofollow ugc noopener\">books</a> and <a href=\"https://books.nilcoalescing.com/bundles?utm_source=nilcoalescing&amp;utm_medium=blog&amp;utm_term=VisualisingDataWithAHexagonalHeatmapInSwiftCharts&amp;utm_content=inline\" rel=\"nofollow ugc noopener\">book bundles</a>.</p>","headings":[{"level":1,"text":"Visualising data with a hexagonal heatmap in Swift Charts","id":"visualising-data-with-a-hexagonal-heatmap-in-swift-charts"}]}}