Skip to content

Commit 176924a

Browse files
odmirKarthikRIyer
authored andcommitted
bfix: Histogram drawData Rewrite (#62)
* Add test for line join problems between two histogram series and add reference images - The test showcases different possible edge cases where the histogram drawing algorithm might fail; - Rename `testHistogramStackedStepLineJoins` to `testHistogramStackedStepOffset` to better reflect its purpose while allowing me to use that name instead; - Change number of file name of the test `testNestedSubplots` from 25 to 26 so the names follow a logical order; - Add/rename reference files. * Rewrite `drawData` method on histogram and update reference images This rewrite attempts to: - Improve the code clarity; - Handle all possible edge cases encountered when drawing lines, when `histogramType == .step`; - Replace the usage of `renderer.drawLine` with the more appropriate `renderer.drawPlotLines`. * Add 4 series stacked histogram tests and add reference images - Add tests for stacked histogram with multiple series, for `.bar` and `.step` types; - Updated tests numbering and add reference images. * Update names of variables like `Series1` or `S2` to use the words "back/front" and "Left/Right Bin" to better describe them * Update `.bar` drawing code to not use an array of rects and update SVG reference images
1 parent 1794440 commit 176924a

28 files changed

+535
-396
lines changed

Sources/SwiftPlot/Histogram.swift

Lines changed: 67 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -276,111 +276,82 @@ extension Histogram: HasGraphLayout {
276276
}
277277
}
278278
}
279-
280-
//functions to draw the plot
279+
280+
/// Draw with rectangles if `histogramType` is `.bar` or with lines if `histogramType` is `.step`
281281
public func drawData(markers: PlotMarkers, size: Size, renderer: Renderer) {
282-
var xM = Float(xMargin)
282+
let binCount = histogramSeries.bins
283+
let allSeries = [histogramSeries] + histogramStackSeries
283284
switch histogramSeries.histogramSeriesOptions.histogramType {
284285
case .bar:
285-
for i in 0..<histogramSeries.bins {
286-
var currentHeight: Float = histogramSeries.scaledBinFrequency[i]
287-
var rect = Rect(
288-
origin: Point(xM, 0),
289-
size: Size(width: barWidth, height: currentHeight)
290-
)
291-
renderer.drawSolidRect(rect,
292-
fillColor: histogramSeries.color,
293-
hatchPattern: .none)
294-
295-
for series in histogramStackSeries {
296-
rect.origin.y = currentHeight
297-
rect.size.height = series.scaledBinFrequency[i]
298-
renderer.drawSolidRect(rect,
299-
fillColor: series.color,
300-
hatchPattern: .none)
301-
currentHeight += series.scaledBinFrequency[i]
286+
let xStart = Float(xMargin)
287+
let xValues = stride(from: xStart, to: xStart + Float(binCount) * barWidth, by: barWidth)
288+
289+
// Get a `Slice` of frequencies for each series so we can take one element from each series for each x value
290+
var frequencySlices = allSeries.map { $0.scaledBinFrequency[...] }
291+
for x in xValues {
292+
var currentHeight: Float = 0.0
293+
for (series, index) in zip(allSeries, frequencySlices.indices) {
294+
let height = frequencySlices[index].removeFirst()
295+
let rect = Rect(origin: Point(x, currentHeight), size: Size(width: barWidth, height:
296+
height))
297+
renderer.drawSolidRect(rect, fillColor: series.color, hatchPattern: .none)
298+
currentHeight += height
302299
}
303-
xM+=barWidth
300+
currentHeight = 0.0
304301
}
305302
case .step:
306-
var firstHeight: Float = histogramSeries.scaledBinFrequency[0]
307-
var firstBottomLeft = Point(xM, 0.0)
308-
var firstTopLeft = Point(xM, firstHeight)
309-
renderer.drawLine(startPoint: firstBottomLeft,
310-
endPoint: firstTopLeft,
311-
strokeWidth: strokeWidth,
312-
strokeColor: histogramSeries.color,
313-
isDashed: false)
314-
for series in histogramStackSeries {
315-
firstBottomLeft = Point(firstBottomLeft.x, firstHeight)
316-
firstTopLeft = Point(firstTopLeft.x, firstHeight + series.scaledBinFrequency[0])
317-
renderer.drawLine(startPoint: firstBottomLeft,
318-
endPoint: firstTopLeft,
319-
strokeWidth: strokeWidth,
320-
strokeColor: series.color,
321-
isDashed: false)
322-
firstHeight += series.scaledBinFrequency[0]
323-
}
324-
for i in 0..<histogramSeries.bins {
325-
var currentHeight: Float = histogramSeries.scaledBinFrequency[i]
326-
var topLeft = Point(xM,currentHeight)
327-
var topRight = Point(xM+barWidth,currentHeight)
328-
renderer.drawLine(startPoint: topLeft,
329-
endPoint: topRight,
330-
strokeWidth: strokeWidth,
331-
strokeColor: histogramSeries.color,
332-
isDashed: false)
333-
if (i != histogramSeries.bins-1) {
334-
let nextTopLeft = Point(topRight.x, histogramSeries.scaledBinFrequency[i+1])
335-
renderer.drawLine(startPoint: topRight,
336-
endPoint: nextTopLeft,
337-
strokeWidth: strokeWidth,
338-
strokeColor: histogramSeries.color,
339-
isDashed: false)
303+
let xStart = Float(xMargin)
304+
let xValues = stride(from: xStart, through: xStart + Float(binCount) * barWidth, by: barWidth)
305+
306+
// One heights array for each series
307+
var seriesHeights: [[Float]] = [[Float](repeating: 0.0, count: binCount + 2)]
308+
309+
// Update `currentHeights` with the height from the series and add ìt to `heights`
310+
var currentHeights = seriesHeights[seriesHeights.startIndex]
311+
for series in allSeries {
312+
for (newHeight, index) in zip(series.scaledBinFrequency, currentHeights.indices.dropFirst().dropLast()) {
313+
currentHeights[index] += newHeight
340314
}
341-
for series in histogramStackSeries {
342-
topLeft = Point(topLeft.x, currentHeight + series.scaledBinFrequency[i])
343-
topRight = Point(topRight.x, currentHeight + series.scaledBinFrequency[i])
344-
if (series.scaledBinFrequency[i] > 0) {
345-
renderer.drawLine(startPoint: topLeft,
346-
endPoint: topRight,
347-
strokeWidth: strokeWidth,
348-
strokeColor: series.color,
349-
isDashed: false)
350-
if (i != histogramSeries.bins-1) {
351-
var nextHeight = histogramSeries.scaledBinFrequency[i+1]
352-
for k in histogramStackSeries {
353-
nextHeight += k.scaledBinFrequency[i+1]
354-
}
355-
let nextTopLeft = Point(topRight.x, nextHeight)
356-
renderer.drawLine(startPoint: topRight,
357-
endPoint: nextTopLeft,
358-
strokeWidth: strokeWidth,
359-
strokeColor: series.color,
360-
isDashed: false)
361-
}
315+
seriesHeights.append(currentHeights)
316+
}
317+
318+
// Draw only the line segments that will actually be visible, unobstructed from other lines that will be on top
319+
// We iterate over the series in reverse to draw them from back to front
320+
var seriesHeightsSlice = seriesHeights.reversed()[...]
321+
var backHeightsSlice = seriesHeightsSlice.removeFirst()[...]
322+
for (frontHeights, series) in zip(seriesHeightsSlice, allSeries.reversed()) {
323+
var frontHeightsSlice = frontHeights[...]
324+
325+
// Iterate over bin edges focusing on the height of the left and right bins of the series on the back and in front
326+
var backLeftBinHeight = backHeightsSlice.removeFirst()
327+
var frontLeftBinHeight = frontHeightsSlice.removeFirst()
328+
var line = [Point]()
329+
for ((backRightBinHeight, frontRightBinHeight), x) in zip(zip(backHeightsSlice, frontHeightsSlice), xValues) {
330+
331+
func endLine() {
332+
renderer.drawPlotLines(points: line, strokeWidth: strokeWidth,
333+
strokeColor: series.color, isDashed: false)
334+
line.removeAll(keepingCapacity: true)
362335
}
363-
currentHeight += series.scaledBinFrequency[i]
336+
337+
// Conditions for appending specific points or ending the line at different places based on the relative heights (4 measures)
338+
let c1 = backLeftBinHeight > frontLeftBinHeight
339+
let c2 = backRightBinHeight > frontRightBinHeight
340+
let c3 = backLeftBinHeight > frontRightBinHeight
341+
let c4 = backRightBinHeight > frontLeftBinHeight
342+
343+
if c1 || c3 && c4 { line.append(Point(x, backLeftBinHeight)) }
344+
if !c3 { endLine() }
345+
if c1 && !c4 { line.append(Point(x, frontLeftBinHeight)) }
346+
if !c4 { endLine() }
347+
if c2 && !c3 { line.append(Point(x, frontRightBinHeight)) }
348+
if c2 || c3 && c4 { line.append(Point(x, backRightBinHeight)) }
349+
if !c2 { endLine() }
350+
351+
backLeftBinHeight = backRightBinHeight
352+
frontLeftBinHeight = frontRightBinHeight
364353
}
365-
xM+=barWidth
366-
}
367-
var lastHeight: Float = histogramSeries.scaledBinFrequency[histogramSeries.scaledBinFrequency.count-1]
368-
var lastBottomRight = Point(xM, 0.0)
369-
var lastTopRight = Point(xM, lastHeight)
370-
renderer.drawLine(startPoint: lastBottomRight,
371-
endPoint: lastTopRight,
372-
strokeWidth: strokeWidth,
373-
strokeColor: histogramSeries.color,
374-
isDashed: false)
375-
for series in histogramStackSeries {
376-
lastBottomRight = Point(lastBottomRight.x, lastHeight)
377-
lastTopRight = Point(lastTopRight.x, lastHeight + series.scaledBinFrequency[series.scaledBinFrequency.count-1])
378-
renderer.drawLine(startPoint: lastBottomRight,
379-
endPoint: lastTopRight,
380-
strokeWidth: strokeWidth,
381-
strokeColor: series.color,
382-
isDashed: false)
383-
lastHeight += series.scaledBinFrequency[series.scaledBinFrequency.count-1]
354+
backHeightsSlice = frontHeights[...]
384355
}
385356
}
386357
}

Tests/SwiftPlotTests/Histogram/histogram-regression-tests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ extension HistogramTests {
1111

1212
/// Regression test [#57]. We used to fail to clear the histogram state between renders,
1313
/// leading to slight offsets in the subsequent AGG and CG renders.
14-
func testHistogramStackedStepLineJoins() throws {
15-
let fileName = "_reg_57_histogram_stacked_step_line_joins"
14+
func testHistogramStackedStepOffset() throws {
15+
let fileName = "_reg_57_histogram_stacked_step_offset"
1616

1717
let histogram = Histogram<Float>(isNormalized: false, enableGrid: true)
1818
histogram.addSeries(data: [5], bins: 10, label: "Plot 1", color: .blue, histogramType: .step)

Tests/SwiftPlotTests/Histogram/histogram-stacked-step.swift

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Foundation
12
import SwiftPlot
23
import SVGRenderer
34
#if canImport(AGGRenderer)
@@ -37,4 +38,63 @@ extension HistogramTests {
3738
verifyImage(name: fileName, renderer: .coreGraphics)
3839
#endif
3940
}
41+
42+
func testHistogramStackedStepLineJoins() throws {
43+
let fileName = "_27_histogram_stacked_step_line_joins"
44+
45+
let histogram = Histogram<Float>(isNormalized: false, enableGrid: true)
46+
histogram.addSeries(data: [0, 13, 17, 17, 21, 25, 30, 34, 34, 38, 42, 45], bins: 49, label: "Plot 1", color: .blue, histogramType: .step)
47+
histogram.addStackSeries(data: [0, 6, 9, 10, 16, 18, 20, 22, 24, 24, 26, 26, 30, 33, 34, 35, 37, 38, 39, 41, 41, 42, 42, 43, 43, 45], label: "Plot 2", color: .orange)
48+
histogram.plotTitle = PlotTitle("HISTOGRAM STACKED STEP LINE JOINS")
49+
histogram.plotLabel = PlotLabel(xLabel: "X", yLabel: "Frequency")
50+
51+
52+
let svg_renderer = SVGRenderer()
53+
try histogram.drawGraphAndOutput(fileName: svgOutputDirectory+fileName,
54+
renderer: svg_renderer)
55+
#if canImport(AGGRenderer)
56+
let agg_renderer = AGGRenderer()
57+
try histogram.drawGraphAndOutput(fileName: aggOutputDirectory+fileName,
58+
renderer: agg_renderer)
59+
#endif
60+
#if canImport(QuartzRenderer)
61+
let quartz_renderer = QuartzRenderer()
62+
try histogram.drawGraphAndOutput(fileName: coreGraphicsOutputDirectory+fileName,
63+
renderer: quartz_renderer)
64+
#endif
65+
}
66+
67+
func testHistogramMultiStackedStep() throws {
68+
let fileName = "_26_histogram_multi_stacked_step"
69+
70+
let x: StrideTo<Float> = stride(from: 0, to: 2 * .pi, by: (2 * .pi)/100)
71+
let data1: [Float] = x.flatMap { [Float](repeating: $0, count: Int((sin($0) + 1)*10)) }
72+
let data2: [Float] = x.flatMap { [Float](repeating: $0, count: Int((cos($0) + 1)*10)) }
73+
let data3: [Float] = x.flatMap { [Float](repeating: $0, count: Int((sin($0 + .pi) + 1)*10)) }
74+
let data4: [Float] = x.flatMap { [Float](repeating: $0, count: Int((cos($0 + .pi) + 1)*10)) }
75+
76+
let histogram = Histogram<Float>(isNormalized: false, enableGrid: true)
77+
histogram.addSeries(data: data1, bins: 40, label: "Plot 1", color: .blue, histogramType: .step)
78+
histogram.addStackSeries(data: data2, label: "Plot 2", color: .orange)
79+
histogram.addStackSeries(data: data3, label: "Plot 3", color: .purple)
80+
histogram.addStackSeries(data: data4, label: "Plot 3", color: .darkRed)
81+
82+
histogram.plotTitle = PlotTitle("HISTOGRAM MULTI STACKED STEP")
83+
histogram.plotLabel = PlotLabel(xLabel: "X", yLabel: "Frequency")
84+
85+
86+
let svg_renderer = SVGRenderer()
87+
try histogram.drawGraphAndOutput(fileName: svgOutputDirectory+fileName,
88+
renderer: svg_renderer)
89+
#if canImport(AGGRenderer)
90+
let agg_renderer = AGGRenderer()
91+
try histogram.drawGraphAndOutput(fileName: aggOutputDirectory+fileName,
92+
renderer: agg_renderer)
93+
#endif
94+
#if canImport(QuartzRenderer)
95+
let quartz_renderer = QuartzRenderer()
96+
try histogram.drawGraphAndOutput(fileName: coreGraphicsOutputDirectory+fileName,
97+
renderer: quartz_renderer)
98+
#endif
99+
}
40100
}

Tests/SwiftPlotTests/Histogram/histogram-stacked.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Foundation
12
import SwiftPlot
23
import SVGRenderer
34
#if canImport(AGGRenderer)
@@ -37,4 +38,38 @@ extension HistogramTests {
3738
verifyImage(name: fileName, renderer: .coreGraphics)
3839
#endif
3940
}
41+
42+
func testHistogramMultiStacked() throws {
43+
let fileName = "_25_histogram_multi_stacked"
44+
45+
let x: StrideTo<Float> = stride(from: 0, to: 2 * .pi, by: (2 * .pi)/100)
46+
let data1: [Float] = x.flatMap { [Float](repeating: $0, count: Int((sin($0) + 1)*10)) }
47+
let data2: [Float] = x.flatMap { [Float](repeating: $0, count: Int((cos($0) + 1)*10)) }
48+
let data3: [Float] = x.flatMap { [Float](repeating: $0, count: Int((sin($0 + .pi) + 1)*10)) }
49+
let data4: [Float] = x.flatMap { [Float](repeating: $0, count: Int((cos($0 + .pi) + 1)*10)) }
50+
51+
let histogram = Histogram<Float>(isNormalized: false, enableGrid: true)
52+
histogram.addSeries(data: data1, bins: 40, label: "Plot 1", color: .blue, histogramType: .bar)
53+
histogram.addStackSeries(data: data2, label: "Plot 2", color: .orange)
54+
histogram.addStackSeries(data: data3, label: "Plot 3", color: .purple)
55+
histogram.addStackSeries(data: data4, label: "Plot 3", color: .darkRed)
56+
57+
histogram.plotTitle = PlotTitle("HISTOGRAM MULTI STACKED")
58+
histogram.plotLabel = PlotLabel(xLabel: "X", yLabel: "Frequency")
59+
60+
61+
let svg_renderer = SVGRenderer()
62+
try histogram.drawGraphAndOutput(fileName: svgOutputDirectory+fileName,
63+
renderer: svg_renderer)
64+
#if canImport(AGGRenderer)
65+
let agg_renderer = AGGRenderer()
66+
try histogram.drawGraphAndOutput(fileName: aggOutputDirectory+fileName,
67+
renderer: agg_renderer)
68+
#endif
69+
#if canImport(QuartzRenderer)
70+
let quartz_renderer = QuartzRenderer()
71+
try histogram.drawGraphAndOutput(fileName: coreGraphicsOutputDirectory+fileName,
72+
renderer: quartz_renderer)
73+
#endif
74+
}
4075
}
-682 Bytes
Loading
-1.11 KB
Loading
17.1 KB
Loading
19.2 KB
Loading
15.9 KB
Loading

0 commit comments

Comments
 (0)