Beyond Scripting with Swift: Bar charts and ToolTips

Graphics and neat aids like ToolTips are daunting for different reasons. Graphics are complex, and most thorough accounts of them quickly become terrifying; ToolTips, on the other hand, appear to be almost undocumented, and the little that Apple provides hasn’t even been proof-read for self-consistency.

woodpile14

The parameters given for the call to add a ToolTip are an unnamed NSRect, owner, and userData, but those listed below in the text are a named NSRect, anObject, and userData. Ho hum.

What I wanted to do in Woodpile is have a custom NSView into which I draw a bar chart of the log load of the current log analysis, then on each bar provide a ToolTip giving details about the underlying data point – its value, date, time and name of the log file containing those data. I had extracted the latter and saved it as an array of strings when processing the log data. Now all I needed to do was display it all in my custom view.

In the absence of any other documentation or examples of ToolTips, I found an article by ‘a Swift developer’ which gave me some ideas. I needed to create two new classes: one for my ToolTips, the other for my custom view.

The ToolTip class is really simple, and is just a mechanism for storing the ToolTip string:
class ToolTip: NSObject {
var tip: String
var tag: NSView.ToolTipTag?

init(tip: String) {
self.tip = tip
super.init()
}

This is the key function, which returns the string when the app asks to view the ToolTip:
override func view(_ view: NSView, stringForToolTip tag: NSView.ToolTipTag, point: NSPoint, userData data: UnsafeMutableRawPointer?) -> String {
return self.tip
} }

Notice how it blithely ignores all the parameters passed to it!

The custom view contains some important variables, and two functions. The first of those functions handles the drawing, and the second is called from the main ViewController to update the bar chart. So my custom ChartView is derived from NSView:
class ChartView: NSView {
var theChartHeights: [Double] = []
var theChartTypes: [Int] = []
var theChartLabels: [String] = []
var theTooltipTags: [ToolTipTag] = []
var theTooltipList: [ToolTip] = []

woodpile20

As in the last article, I’m going to explain the working functions in reverse order. The first takes arrays containing the raw values for the height of each chart bar, the colour class of each bar, and the labels to be used for their ToolTips, and tucks those in the view’s variables. It then calls for the view to be displayed, which should force the other function draw().
func setArrays(theCH: [Double], theCT: [Int], theCL: [String]) {
theChartHeights = theCH
theChartTypes = theCT
theChartLabels = theCL
display()
}

All the work is done in the draw() function. Because this is called each time the app needs to draw some or all of the ChartView, this needs to be as quick and efficient as possible. In a perfect world, all it should need to do is draw the bars as filled rectangles, but it also has to take into account changes to the view size, its bounds, resulting from adjustments to the enclosing window.

In this case, that requires the complete computation of all the rectangles, and replacement of all the ToolTips. I have yet to experiment to see if my custom ToolTip class might be able to update its rectangles, but as there appears to be no standard function to do that, I suspect that will be doomed to failure. In any case, it wouldn’t save much effort in drawing the view.

The first task is to empty the view’s other arrays, and remove all existing ToolTips.
override func draw(_ dirtyRect: NSRect) {
theTooltipTags = []
theTooltipList = []
removeAllToolTips()

It then draws the simplest element in the view, a horizontal line 10 points up from the bottom, on which the bars will rest. I’ll make this light grey in colour.
NSColor.lightGray.set()
let path = NSBezierPath()
path.move(to: NSPoint(x: 0.0, y: 10.0))
path.line(to: NSPoint(x: bounds.width, y: 10.0))
path.stroke()

When this view is first opened, no log data have been read, so the next thing to check is that there are some data to handle; if not, I won’t try to process it.
if theChartHeights.count > 0 {

Now set some basic dimensions for drawing. First, the unscaled maximum height of a bar is the maximum value in theChartHeights array. I then work out the scaling factor, to make the maximum bar 10 points short of the top of the view, and 10 points short of the bottom. All the dimensions go into floating point Doubles, so that they can go into an NSRect easily.
let theMaxHeight = theChartHeights.max()
let theYScale = Double(bounds.height - 20.0)/theMaxHeight!

The lower left (start) of the first bar is fixed at (10, 10). As I set each bar up as a drawing rectangle, I’m going to increment the X value, to place the bars from left to right across the view, so the X coordinate is a variable. I then work out the X increment, that is the width of each bar. I make that a maximum of 50 points, but as I’m using floating point, I don’t set a minimum. It’s good to see what a Retina display can do!
var theX = 10.0
let theY = 10.0
var theXInc = Double(bounds.width - 20.0)/Double(theChartHeights.count)
if (theXInc > 50.0) { theXInc = 50.0 }

Now I have to plod through theChartHeights array.
for (index, value) in theChartHeights.enumerated() {

I need to look up the integer colour code in theChartTypes array, scale the data value for that bar to the view, then make the NSRect which is to be drawn to form that bar.
let theCode = theChartTypes[index]
let theHeight = value * theYScale
let theRect = NSRect(x: theX, y: theY, width: theXInc, height: theHeight)

The final step before I can draw the bar is to use its integer colour code to work out which colour to make it. For the moment, I’ll just use the supplied colours for the sake of simplicity.
if (theCode < 2) {
NSColor.blue.set()
} else if (theCode < 3) {
NSColor.cyan.set()
} else if (theCode < 4) {
NSColor.red.set()
} else if (theCode < 5) {
NSColor.yellow.set()
} else {
NSColor.lightGray.set()
}

Having set the colour for drawing, I’m ready to call for a filled rectangle using that specific NSRect.
NSBezierPath(rect: theRect).fill()

Now I have to sort those ToolTips out: fetch the string from theChartLabels array, create a ToolTip object, and add it using addToolTip(). This is where my first class comes in. For the moment, I’ll also add the ToolTipTag to a list, and the same for the ToolTips themselves, although those may not really be necessary.
let theTipText = theChartLabels[index]
let toolTip = ToolTip(tip: theTipText)
let theTTT = addToolTip(theRect, owner: toolTip, userData: nil)
theTooltipTags.append(theTTT)
theTooltipList.append(toolTip)

Finally, I increment the lower left point, ready to make the next chart bar.
theX = theX + theXInc
} } }

woodpile21

The end result works very well. Even on very skinny bars in data-packed views, the ToolTips are highly usable, and neither they nor the user seems to get confused. And even large datasets are drawn suitably briskly.

Hopefully this worked and working example shows how you can create very detailed ToolTips, and how graphics needn’t be in the least bit difficult.

woodpile18