Skip to content

Commit a197a9f

Browse files
committed
Heatmap improvements:
- Default five-color gradient - Allows showing a grid - Aligns marker labels centrally in the block's space - Adjusts plot size to exactly fit the data, for a cleaner image - Added styling closure, similar to BarGraph - Added a heatmap example, using Boston temperature data (more tests are coming)
1 parent 840d7ef commit a197a9f

File tree

2 files changed

+106
-81
lines changed

2 files changed

+106
-81
lines changed

Sources/SwiftPlot/Heatmap.swift

Lines changed: 66 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11

22
// Todo list for Heatmap:
3-
// - Shift grid to block bounds
4-
// - Draw grid over blocks?
53
// - Spacing between blocks
64
// - Setting X/Y axis labels
75
// - Displaying colormap next to plot
8-
// - Collection slicing by filter closure
6+
// - Collection slicing by filter closure?
97

108
/// A heatmap is a plot of 2-dimensional data, where each value is assigned a colour value along a gradient.
119
///
@@ -19,26 +17,39 @@ public struct Heatmap<SeriesType> where SeriesType: Sequence, SeriesType.Element
1917

2018
public var values: SeriesType
2119
public var interpolator: Interpolator<Element>
22-
public var colorMap: ColorMap = .linear(.orange, .purple)
20+
public var colorMap: ColorMap = .fiveColorHeatMap
2321

2422
public init(values: SeriesType, interpolator: Interpolator<Element>) {
2523
self.values = values
2624
self.interpolator = interpolator
27-
// self.layout.yMarkerMaxWidth = 100
28-
// self.layout.enablePrimaryAxisGrid = false
25+
self.layout.drawsGridOverForeground = true
26+
self.layout.markerLabelAlignment = .betweenMarkers
27+
self.showGrid = false
28+
}
29+
}
30+
31+
// Customisation properties.
32+
33+
extension Heatmap {
34+
35+
public var showGrid: Bool {
36+
get { layout.enablePrimaryAxisGrid }
37+
set { layout.enablePrimaryAxisGrid = newValue }
2938
}
3039
}
3140

3241
// Layout and drawing.
3342

3443
extension Heatmap: HasGraphLayout, Plot {
3544

36-
public struct DrawingData {
45+
public struct DrawingData: AdjustsPlotSize {
3746
var values: SeriesType?
3847
var range: (min: Element, max: Element)?
3948
var itemSize = Size.zero
4049
var rows = 0
4150
var columns = 0
51+
52+
var desiredPlotSize = Size.zero
4253
}
4354

4455
public func layoutData(size: Size, renderer: Renderer) -> (DrawingData, PlotMarkers?) {
@@ -51,7 +62,7 @@ extension Heatmap: HasGraphLayout, Plot {
5162
}
5263
var (maxValue, minValue) = (firstElem, firstElem)
5364

54-
// Discover the maximum/minimum values and shape of the data.
65+
// - Discover the maximum/minimum values and shape of the data.
5566
var totalRows = 0
5667
var maxColumns = 0
5768
for row in values {
@@ -64,32 +75,34 @@ extension Heatmap: HasGraphLayout, Plot {
6475
maxColumns = max(maxColumns, columnsInRow)
6576
totalRows += 1
6677
}
78+
79+
// - Calculate the element size.
80+
var elementSize = Size(
81+
width: size.width / Float(maxColumns),
82+
height: size.height / Float(totalRows)
83+
)
84+
// We prefer showing smaller elements with integer dimensions to avoid aliasing.
85+
if elementSize.width > 1 { elementSize.width.round(.down) }
86+
if elementSize.height > 1 { elementSize.height.round(.down) }
87+
6788
// Update results.
6889
results.values = values
6990
results.range = (minValue, maxValue)
7091
results.rows = totalRows
7192
results.columns = maxColumns
72-
results.itemSize = Size(
73-
width: size.width / Float(results.columns),
74-
height: size.height / Float(results.rows)
75-
)
76-
// If we have at enough space to give each element a pixel,
77-
// round the items down to integer dimensions to remove aliasing
78-
// in the rendered image.
79-
if results.itemSize.width > 1 {
80-
results.itemSize.width = max(results.itemSize.width.rounded(.down), 1)
81-
}
82-
if results.itemSize.height > 1 {
83-
results.itemSize.height = max(results.itemSize.height.rounded(.down), 1)
84-
}
93+
results.itemSize = elementSize
94+
// The size rounding may leave a gap between the data and the border,
95+
// so let the layout know we desire a smaller plot.
96+
results.desiredPlotSize = Size(width: Float(results.columns) * results.itemSize.width,
97+
height: Float(results.rows) * results.itemSize.height)
98+
8599
// Calculate markers.
86100
markers.xMarkers = (0..<results.columns).map {
87-
(Float($0) + 0.5) * results.itemSize.width
101+
Float($0) * results.itemSize.width
88102
}
89103
markers.yMarkers = (0..<results.rows).map {
90-
(Float($0) + 0.5) * results.itemSize.height
104+
Float($0) * results.itemSize.height
91105
}
92-
// TODO: Shift grid by -0.5 * itemSize.
93106

94107
// TODO: Allow setting the marker text.
95108
markers.xMarkersText = (0..<results.columns).map { String($0) }
@@ -167,8 +180,14 @@ extension SequencePlots where Base.Element: Sequence {
167180
/// - parameters:
168181
/// - interpolator: A function or `KeyPath` which maps values to a continuum between 0 and 1.
169182
/// - returns: A heatmap plot of the sequence's inner items.
170-
public func heatmap(interpolator: Interpolator<Base.Element.Element>) -> Heatmap<Base> {
171-
return Heatmap(values: base, interpolator: interpolator)
183+
public func heatmap(
184+
interpolator: Interpolator<Base.Element.Element>,
185+
style: (inout Heatmap<Base>)->Void = { _ in }
186+
) -> Heatmap<Base> {
187+
188+
var graph = Heatmap(values: base, interpolator: interpolator)
189+
style(&graph)
190+
return graph
172191
}
173192
}
174193

@@ -181,7 +200,12 @@ extension SequencePlots where Base: Collection {
181200
/// - returns: A heatmap plot of the collection's values.
182201
/// - complexity: O(n). Consider though, that rendering a heatmap or copying to a `RamdomAccessCollection`
183202
/// is also at least O(n), and this does not copy the data.
184-
public func heatmap(width: Int, interpolator: Interpolator<Base.Element>) -> Heatmap<[Base.SubSequence]> {
203+
public func heatmap(
204+
width: Int,
205+
interpolator: Interpolator<Base.Element>,
206+
style: (inout Heatmap<[Base.SubSequence]>)->Void = { _ in }
207+
) -> Heatmap<[Base.SubSequence]> {
208+
185209
precondition(width > 0, "Cannot build a histogram with zero or negative width")
186210
var rows = [Base.SubSequence]()
187211
var rowStart = base.startIndex
@@ -193,7 +217,7 @@ extension SequencePlots where Base: Collection {
193217
rows.append(base[rowStart..<rowEnd])
194218
rowStart = rowEnd
195219
}
196-
return rows.plots.heatmap(interpolator: interpolator)
220+
return rows.plots.heatmap(interpolator: interpolator, style: style)
197221
}
198222
}
199223

@@ -204,25 +228,35 @@ extension SequencePlots where Base: RandomAccessCollection {
204228
/// - width: The width of the heatmap to generate. Must be greater than 0.
205229
/// - interpolator: A function or `KeyPath` which maps values to a continuum between 0 and 1.
206230
/// - returns: A heatmap plot of the collection's values.
207-
public func heatmap(width: Int, interpolator: Interpolator<Base.Element>) -> Heatmap<[Base.SubSequence]> {
231+
public func heatmap(
232+
width: Int,
233+
interpolator: Interpolator<Base.Element>,
234+
style: (inout Heatmap<[Base.SubSequence]>)->Void = { _ in }
235+
) -> Heatmap<[Base.SubSequence]> {
236+
208237
precondition(width > 0, "Cannot build a histogram with zero or negative width")
209238
let height = Int((Float(base.count) / Float(width)).rounded(.up))
210239
return (0..<height)
211240
.map { base._sliceForRow($0, width: width) }
212-
.plots.heatmap(interpolator: interpolator)
241+
.plots.heatmap(interpolator: interpolator, style: style)
213242
}
214243

215244
/// Returns a heatmap of this collection's values, generated by the data in to `height` rows.
216245
/// - parameters:
217246
/// - height: The height of the heatmap to generate. Must be greater than 0.
218247
/// - interpolator: A function or `KeyPath` which maps values to a continuum between 0 and 1.
219248
/// - returns: A heatmap plot of the collection's values.
220-
public func heatmap(height: Int, interpolator: Interpolator<Base.Element>) -> Heatmap<[Base.SubSequence]> {
249+
public func heatmap(
250+
height: Int,
251+
interpolator: Interpolator<Base.Element>,
252+
style: (inout Heatmap<[Base.SubSequence]>)->Void = { _ in }
253+
) -> Heatmap<[Base.SubSequence]> {
254+
221255
precondition(height > 0, "Cannot build a histogram with zero or negative height")
222256
let width = Int((Float(base.count) / Float(height)).rounded(.up))
223257
return (0..<height)
224258
.map { base._sliceForRow($0, width: width) }
225-
.plots.heatmap(interpolator: interpolator)
259+
.plots.heatmap(interpolator: interpolator, style: style)
226260
}
227261
}
228262

Tests/SwiftPlotTests/Heatmap/heatmap.swift

Lines changed: 40 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -12,60 +12,51 @@ struct MyStruct {
1212
var val: Int
1313
}
1414

15+
1516
@available(tvOS 13.0, watchOS 6.0, *)
1617
final class HeatmapTests: SwiftPlotTestCase {
1718

1819
func testHeatmap() throws {
19-
let fileName = "_____heatmap"
20-
21-
var hm = Heatmap<[[Int]]>()
22-
hm.values = [
23-
(0..<5).map { _ in .random(in: -10...10) },
24-
(0..<6).map { _ in .random(in: -10...10) },
25-
(0..<10).map { _ in .random(in: -10...10) },
26-
(0..<7).map { _ in .random(in: -10...10) },
27-
(0..<8).map { _ in .random(in: -10...10) },
28-
]
29-
hm.plotTitle.title = "😅"
30-
31-
var d = Data(capacity: 10_000)
32-
for _ in 0..<10_000 { d.append(.random(in: 0..<255)) }
33-
// var hm3 = d.heatmap(width: 100, interpolator: .linear)
34-
35-
var hm3 = "THIS IS SWIFPLOT!!!! Woo let's see what this looks like :)"
36-
.heatmap(width: 5, interpolator: .linearByKeyPath(\.asciiValue!))
37-
// var hm3 = Array(stride(from: Float(0), to: 1, by: 0.001)).heatmap(width: 10, interpolator: .linear)
38-
// var hm3 = "😘 you too".utf8.heatmap(width: 5, interpolator: .linear)
39-
hm3.colorMap = .viridis
40-
41-
42-
var hm2 = Heatmap<[[Int]]>(interpolator: .linear)//.inverted)
43-
hm2.values = (0..<10).map { row in
44-
(0..<10).map { col in 0 }
20+
// Example from:
21+
// https://scipython.com/book/chapter-7-matplotlib/examples/a-heatmap-of-boston-temperatures/
22+
let data: [[Float]] = median_daily_temp_boston_2012
23+
let heatmap = data.plots
24+
.heatmap(interpolator: .linear) {
25+
$0.plotTitle.title = "Maximum daily temperatures in Boston, 2012"
26+
$0.plotLabel.xLabel = "Day of the Month"
27+
$0.colorMap = ColorMap.fiveColorHeatMap.lightened(by: 0.35)
28+
$0.showGrid = true
29+
$0.grid.color = Color.gray.withAlpha(0.65)
4530
}
46-
hm2.values[8][2] = 1
47-
hm2.values[8][6] = 1
48-
hm2.values[6][2] = 1
49-
hm2.values[6][6] = 1
50-
hm2.values[5][2...5] = Array(repeating: 1, count: 4)[...]
51-
hm2.colorMap = .inferno
52-
53-
var sub = SubPlot(layout: .horizontal)
54-
sub.plots = [hm3]//, hm2]
31+
try renderAndVerify(heatmap, size: Size(width: 900, height: 450))
32+
}
33+
34+
func testHeatmap2() throws {
5535

56-
let svg_renderer = SVGRenderer()
57-
try sub.drawGraphAndOutput(fileName: svgOutputDirectory+fileName,
58-
renderer: svg_renderer)
59-
#if canImport(AGGRenderer)
60-
let aggRenderer = AGGRenderer()
61-
try sub.drawGraphAndOutput(fileName: aggOutputDirectory + fileName,
62-
renderer: aggRenderer)
63-
#endif
64-
#if canImport(QuartzRenderer)
65-
let quartz_renderer = QuartzRenderer()
66-
try sub.drawGraphAndOutput(fileName: coreGraphicsOutputDirectory+fileName,
67-
renderer: quartz_renderer)
68-
verifyImage(name: fileName, renderer: .coreGraphics)
69-
#endif
36+
let summer = median_daily_temp_boston_2012[5...7].plots
37+
.heatmap(interpolator: .linear) {
38+
$0.colorMap = ColorMap.viridis//.lightened(by: 0.2)
39+
$0.showGrid = true
40+
}
41+
try renderAndVerify(summer, size: Size(width: 900, height: 450))
7042
}
7143
}
44+
45+
46+
// Data used to generate Heatmaps.
47+
48+
49+
let median_daily_temp_boston_2012: [[Float]] = [
50+
/* Jan */ [11.1, 10.2, 1.7, -2.0, 3.9, 8.9, 15.7, 7.3, 4.5, 8.5, 3.6, 5.6, 12.3, 1.3, -7.0, 1.3, 9.0, 11.2, 0.7, 0.1, -4.9, -1.1, 8.4, 13.5, 6.2, 5.1, 7.8, 7.9, 6.3, 4.6, 8.5],
51+
/* Feb */ [15.0, 7.4, 3.9, 6.3, 2.4, 10.2, 7.8, 3.5, 8.5, 10.0, 4.0,-0.9, 5.1, 6.7, 6.1, 7.3, 11.8, 8.4, 7.9, 5.1, 6.9, 13.9, 12.8, 5.7, 7.4, 5.1, 11.2, 9.1, 2.3],
52+
/* Mar */ [2.8, 1.8, 8.4, 5.7, 3.5, 4.0, 16.9, 20.1, 16.2, 4.6, 14.7, 21.8, 21.9, 14.0, 5.7, 7.9, 9.6, 23.5, 23.4, 19.5, 25.7, 28.4, 24.6, 15.1, 9.0, 10.0, 9.6, 10.1, 7.8, 10.8, 6.2],
53+
/* Apr */ [10.6, 11.7, 15.1, 17.9, 11.2, 11.9, 11.2, 10.1, 14.5, 17.3, 12.4, 13.5, 18.6, 21.9, 25.2, 30.7, 29.0, 16.8, 19.0, 25.2, 25.7, 16.7, 17.4, 14.7, 16.7, 17.3, 14.1, 15.7, 15.2, 12.4],
54+
/* May */ [10.1, 11.3, 10.0, 13.5, 14.5, 12.4, 15.1, 14.6, 17.3, 19.5, 18.0, 26.8, 26.7, 18.0, 23.0, 22.9, 21.2, 18.5, 18.9, 21.8, 15.1, 17.3, 21.7, 21.7, 23.4, 30.2, 22.3, 20.8, 19.6, 24.1, 28.5],
55+
/* Jun */ [18.6, 16.7, 16.7, 11.7, 13.4, 16.3, 18.5, 26.3, 26.2, 24.5, 23.5, 23.5, 18.3, 20.2, 20.7, 18.5, 17.4, 18.0, 24.7, 36.3, 35.8, 35.2, 27.3, 28.9, 22.9, 23.0, 25.1, 28.4, 31.9, 32.3],
56+
/* Jul */ [32.9, 29.1, 30.2, 29.0, 28.4, 26.8, 30.1, 31.7, 28.9, 28.5, 26.7, 30.1, 32.4, 32.9, 32.8, 31.2, 36.1, 31.7, 23.5, 21.8, 23.4, 28.9, 30.2, 32.8, 28.4, 29.1, 26.3, 22.4, 23.0, 27.2, 22.8],
57+
/* Aug */ [25.6, 30.2, 33.4, 27.8, 31.2, 29.5, 25.1, 28.3, 28.5, 28.9, 27.4, 29.0, 30.0, 28.4, 29.0, 29.6, 30.0, 22.3, 22.9, 23.9, 28.5, 25.6, 30.6, 25.6, 25.2, 24.7, 29.6, 30.1, 24.0, 28.5, 32.4],
58+
/* Sep */ [26.7, 22.9, 22.3, 22.8, 27.9, 26.3, 28.0, 27.9, 24.1, 20.6, 22.8, 22.9, 27.9, 27.2, 23.4, 21.7, 21.2, 24.1, 22.4, 16.7, 17.3, 19.6, 21.9, 19.6, 23.0, 23.3, 20.6, 14.7, 14.1, 15.2],
59+
/* Oct */ [20.1, 21.8, 17.9, 16.9, 24.7, 25.6, 14.6, 13.4, 14.5, 16.8, 15.2, 12.3, 11.8, 19.5, 23.4, 17.4, 13.4, 15.7, 20.2, 23.5, 17.9, 20.2, 19.7, 12.8, 15.2, 18.9, 15.6, 13.3, 16.3, 17.4, 13.5],
60+
/* Nov */ [13.9, 11.2, 11.9, 11.7, 7.4, 5.2, 7.2, 4.7, 11.7, 13.0, 16.1, 19.1, 17.4, 7.3, 5.2, 7.4, 8.5, 8.5, 8.9, 9.1, 9.5, 10.2, 9.6, 9.0, 4.7, 7.9, 4.0, 2.3, 6.3, 3.6],
61+
/* Dec */ [0.1, 11.2, 15.1, 14.0, 14.0, 5.1, 6.8, 8.5, 9.6, 15.7, 13.4, 4.6, 5.6, 9.6, 4.5, 3.9, 7.2, 10.1, 7.4, 6.2, 11.3, 3.6, 3.5, 4.6, 1.9, 4.0, 8.4, 2.8, 3.0,-1.1, 1.1]
62+
]

0 commit comments

Comments
 (0)