Making of Self-Organizing Map Chart - A Tutorial on Interface Builder Interactive Custom Controls in Swift

sample_json.png

Developing custom controls in Swift is usually required based on the application you are working on. Recently, I've been developing an app that I was required to show data analysis output on a Self-Organizing Map (SOM) chart. Today, I've decided to write this tutorial and share my experience with you. 

In this tutorial you will learn about:

  • Basic concept of creating custom controls

  • Use UIBezier curves to draw shapes

  • Using CGGraphics commands to replicate and shapes

  • Enable Interface Builder interaction for your control

If you're wondering about self-organizing map, it is a type of Artificial Neural Networks (ANN) that is trained using competitive learning to map multi dimension data into lower and visualizable dimensions. Read more on Wikipedia

Before we jump into coding, let's have a clear view of what we are going to make. An SOM chart is typically consist of a beehive shaped set of hexagons where each one of them are represented by a color. In various settings, the color might represent different meanings but the most common use of SOM charts is to show distance between units (neurons) on the 2D space. So the warmer color would mean larger distance and cooler color means less distance. 

As I mentioned in the title, we are going to make the component interactive in Interface Builder so the final product would look like this:

InterfaceBuilder.gif

Our first step is to create a new Swift file and create a class for the SOM Chart. As you can see, we put @IBDesignable on the component so we’re telling XCode that this component should be editable in InterafaceBuilder

import UIKit
@IBDesignable
class SOMChart : UIView {
}

Now, let’s complete the class by adding out main parameters.

Most of the parameters bellow are straight forward but I need to talk about distance matrix. The distance matrix is an array in which we show the distance of each node from the baseline (aka we define the value for the unit). Basically we can pass the output of our SOM algorithm (from Matlab, Python or anywhere else that we have it) to this array and have it visualized. If we do not set the distance matrix, the component shows a gradient of red to black color.

let π:CGFloat = CGFloat(Double.pi)
    
@IBInspectable var units : Int = 4
@IBInspectable var hitUnit : Int = 0 {
    didSet {
        if hitUnit > units * units {
            hitUnit = units * units
        }
        if (hitUnit < 0) {
            hitUnit = 0
        }
        setNeedsDisplay()

    }
}

@IBInspectable var hitColor : UIColor = UIColor.green
@IBInspectable var unitBorderColor : UIColor = UIColor.white
@IBInspectable var unitBorderWidth : CGFloat = 1.0

@IBInspectable var minimumColor : UIColor = UIColor.blue
@IBInspectable var maximumColor : UIColor = UIColor.red
@IBInspectable var reverseColorMap : Bool = true

var distanceMatrix : [Double]? {
    didSet {
        if (distanceMatrix != nil) {
            let n = Double((distanceMatrix?.count)!)
            let n2 = (sqrt(n) + 1) / 2
            units = Int(n2)
        }
    }
}

We need one helper function as well which help us generated an intermediate color (between min and max colors) based on the cell value.

func hexColorFromDistance (_ hexNo: Int) -> UIColor {

    var hexColor = UIColor()
    let rate = distanceMatrix![hexNo - 1] / (distanceMatrix?.max())!
    var startHue = CGFloat()
    var startSat = CGFloat()
    var startBri = CGFloat()
    var startAlpha = CGFloat()
    var endHue = CGFloat()
    var endSat = CGFloat()
    var endBri = CGFloat()
    var endAlpha = CGFloat()
                                             
    minimumColor.getHue(&startHue, saturation: &startSat, brightness: &startBri, alpha: &startAlpha)
    maximumColor.getHue(&endHue, saturation: &endSat, brightness: &endBri, alpha: &endAlpha)

    if endHue == 1.0 && reverseColorMap == true {
        endHue = 0.0
    }

    let h = startHue + ((endHue - startHue) * CGFloat(rate))
    let s = startSat + ((endSat - startSat) * CGFloat(rate))
    let b = startBri + ((endBri - startBri) * CGFloat(rate))

    hexColor = UIColor(hue: h, saturation: s, brightness: b, alpha: 1.0)

    return hexColor

}

The next step is to actually draw the hexagons on the page:

override func draw(_ rect: CGRect) {
    let context = UIGraphicsGetCurrentContext()
    let hexPerRow = units + (units - 1)
    let nHex = pow(Double(hexPerRow), 2)
    let spaceAvailable = min(rect.width, rect.height)
    let hexHeight = spaceAvailable / CGFloat(hexPerRow)
    let hexWidth = sin(π/3) * hexHeight

    //1 - save original state
    context!.saveGState()

  //2 - Create our hexagon
    let hexPath = UIBezierPath()

    hexPath.move(to: CGPoint(x:hexWidth/2,y:0))
    hexPath.addLine(to: CGPoint(x: hexWidth, y: hexHeight / 4))
    hexPath.addLine(to: CGPoint(x: hexWidth, y: 3 * hexHeight / 4))
    hexPath.addLine(to: CGPoint(x: hexWidth / 2, y: hexHeight))
    hexPath.addLine(to: CGPoint(x: 0, y: 3 * hexHeight / 4))
    hexPath.addLine(to: CGPoint(x: 0, y: hexHeight / 4))
    hexPath.close()

    var hexNo = 0.0
    var unitNo = 0
    let colorStep = 1.0 / nHex
    let xTrans = [hexWidth / 2, 0, hexWidth / 2, hexWidth]
    let x_inset = (rect.width - hexWidth * CGFloat(hexPerRow + 1)) / 2
    let y_inset = (rect.height - (hexHeight * CGFloat(hexPerRow - 1) * 3 / 4 ) - hexHeight ) / 2

    for ii in 1...hexPerRow {
        for jj in 1...hexPerRow {
            context!.saveGState()
            // Determine if current hex is a unit and if it has been hit
            hexNo += 1.0
            var isUnit = false
            var isHit = false
            if (ii % 2) == 1 && (jj % 2) == 1 {
                unitNo += 1
                isUnit = true
                if unitNo == hitUnit {
                    isHit = true
                }
            }

            // Transform Context to place Hex in correct place 

            let transX = x_inset + (CGFloat(ii-1) * hexWidth) + xTrans[jj % 4]
            let transY = y_inset + CGFloat(jj-1) * hexHeight * 3 / 4


            context!.translateBy(x: transX,
                                  y: transY)

            // Set Fill Color
            var faceColor = UIColor()

            if distanceMatrix == nil {
                faceColor = UIColor(red: CGFloat(hexNo * colorStep), green: 0.2, blue: 0.2, alpha: 1.0)
            } else {
                faceColor = hexColorFromDistance(Int(hexNo))
            }

            if (isHit) {
                faceColor = hitColor
            }

            faceColor.setFill()
            hexPath.fill()

            // set border if required
            if isUnit {
                let borderColor = unitBorderColor
                borderColor.setStroke()
                hexPath.lineWidth = unitBorderWidth
                hexPath.stroke()
            }
            context!.restoreGState()
        }
    }

    //8 - restore the original state in case of more painting
    context!.restoreGState()
}

Now, our component is ready to be used on the interface builder. We can also feed it with data on runtime. Here is an example:

class ViewController: UIViewController {

    @IBOutlet weak var somChart: SOMChart!
    var dataValues : [Double]?
     override func viewDidLoad() {
        super.viewDidLoad()
        // Load a sammple JSON file 
        if let path = Bundle.main.path(forResource: "sample_som", ofType: "json")
        {
            if let jsonData = NSData(contentsOfFile: path)
            {
                do {
                    let json = try JSONSerialization.jsonObject(with: jsonData as Data, options: JSONSerialization.ReadingOptions()) as! NSDictionary
                    dataValues = json["data"] as? [Double]
                    somChart.distanceMatrix = dataValues
                } catch {
                    print(error)
                }
            }
        }
    }
}

Hope was useful for you. You can see the complete SOM Chart code with and example Xcode project (tested on Swift 4.0) on my Github.

Happy Coding!