Matthew Nichols
Resume
← Blog

Drawing a tree diagram with Raphaël and CoffeeScript

I recently had to create an interactive tree diagram displaying companies and their subsidiaries and the business requirements specified something "prettier" than I could achieve by styling a nested HTML list.  Additionally I had to support Internet Explorer 8, which does not have Canvas or SVG support.  Pondering the problem I remembered hearing on The Changelog of a client-side drawing library called Raphaël.  Raphaël provides an abstraction over SVG on browsers that support it and on IE does the same thing using VML and thus giving me the ability to draw pictures in the browser going all the way back to IE 6.

So armed with the ability to draw I created my tree.  Most of the new client-side code that I am writing today is in CoffeeScript so that is what I used here.  The following is my main drawing routine:

defaults =
    initialX: 50
    initialY: 50
    boxHeight: 100
    boxWidth: 200
    horizontalSpace: 0
    verticalSpace: 75
    indicatorSize: 40
    indicatorMargin: 10
    defaultNodeFillColor: '#FFF'
    selectedNodeFillColor: '#989FED'
    labelFont: { font: "16px Times", opacity: 1 }
    nodeAfterClickHandler: (node) ->
    openCloseAfterClickHandler: (node) ->

class SVGTree
    constructor: (options) ->       
        @options = $.extend {}, defaults, options
       
        @paper = new Raphael @options.elementId, '100%', '100%'
        @backgroundColor = $('#' + @options.elementId).css("background-color")
        @currX
        @currY
        @xInc = @options.boxWidth + @options.horizontalSpace
        @yInc = @options.boxHeight + @options.verticalSpace
        @currentlySelectedNode
        @defaultNodeFill = { fill: @options.defaultNodeFillColor }
        @selectedNodeFill = { fill: @options.selectedNodeFillColor }

        #Event callbacks
        self = @
        @openCloseClickHandler = () ->
            handleOpenClose.call(self, this)
            self.options.openCloseAfterClickHandler.call(self, this)
       
        @nodeClickHandler = () ->
            handleNodeClick.call(self, this)
            self.options.nodeAfterClickHandler.call(self, this)
       
    render: ->
        #this can be called over and over to refresh so reset initial state
        @paper.clear()
        @currX = @options.initialX
        @currY = @options.initialY
        @renderingTop = true #the rendering of the top node is just a little different so keep track

        #define functions
        renderNode = (elementData, x) =>
            rect = @paper.rect x, @currY, @options.boxWidth, @options.boxHeight
            rect.data('identifier', elementData.identifier)
            rect.attr(if elementData.selected then @selectedNodeFill else @defaultNodeFill)
            if elementData.selected then @currentlySelectedNode = rect
            rect.click @nodeClickHandler

            createTextLabel rect, elementData

            @currY = @currY + @yInc
           
            if elementData.children?
                drawOpenCloseIndicator rect, elementData
                if elementData.childrenOpen
                    for child in elementData.children
                        childNode = renderNode child, x + @xInc
                        drawConnector rect, childNode, child
           
            rect
       
        drawOpenCloseIndicator = (node, elementData) =>
            startXOffset = if @renderingTop then @options.indicatorMargin else (@options.boxWidth / 2 + @options.horizontalSpace) + @options.indicatorMargin
            indicatorHeight = Math.round(@options.indicatorSize * 0.866) #Aprox Math.sqrt(3) / 2
            indicatorStartX = node.attr("x") - @options.indicatorSize - startXOffset
            indicatorStartY = node.attr("y") + ((@options.boxHeight - indicatorHeight) / 2)
                   
            calculatedPath = if elementData.childrenOpen
                "M #{indicatorStartX} #{indicatorStartY} l #{indicatorHeight} 0 l -#{indicatorHeight / 2} #{indicatorHeight} l -#{indicatorHeight / 2} -#{indicatorHeight}"
            else
                "M #{indicatorStartX} #{indicatorStartY} l 0 #{indicatorHeight} l #{indicatorHeight} -#{indicatorHeight / 2} l -#{indicatorHeight} -#{indicatorHeight / 2}"

            #Create a rectangle with the same click handler to surround the visible indicator to make the click easier to hit
            target = @paper.rect(indicatorStartX - 6, indicatorStartY - 6, @options.indicatorSize + 10, @options.indicatorSize + 10)
            target.attr "stroke-opacity": 0
            target.attr "fill": @backgroundColor
            target.attr "fill-opacity": 0
            target.data("elementData", elementData)
            target.click @openCloseClickHandler

            triangle = @paper.path calculatedPath
            triangle.attr({fill: '#000'})
            triangle.data("elementData", elementData)
            triangle.click @openCloseClickHandler

            @renderingTop = false

        #Handle name wrapping
        createTextLabel = (rect, elementData) =>
            textLabel = @paper.text(rect.attr('x') + (@options.boxWidth / 2), @currY + (@options.boxHeight / 2), "")
                .attr(@options.labelFont).data('identifier', elementData.identifier)
            textLabel.click () => @nodeClickHandler.call(rect)

            tempText = " "
            for word in elementData.name.split(" ")
                textLabel.attr("text", tempText + " " + word)
                tempText += if textLabel.getBBox().width > @options.boxWidth then "
" + word else " " + word

            textLabel.attr("text", tempText.substring 1)
            return textLabel

        drawConnector = (parentNode, childNode, inChildPosition) =>
            parentBottomY = parentNode.attr("y") + @options.boxHeight
            parentBottomX = parentNode.attr("x") + (@options.boxWidth / 2)
            childSideY = childNode.attr("y") + (@options.boxHeight / 2)
            childSideX = childNode.attr("x")
            calculatedPath = "M #{parentBottomX} #{parentBottomY} L #{parentBottomX} #{childSideY} L #{childSideX} #{childSideY}"
            connector1 = @paper.path calculatedPath

        #execute
        renderNode @options.nodeData, @currX

    handleOpenClose = (node) ->
        elementData = node.data("elementData")
        elementData.childrenOpen = not elementData.childrenOpen
        @render() #redraw

    handleNodeClick = (node) ->
        @currentlySelectedNode.attr @defaultNodeFill
        node.attr @selectedNodeFill
        @currentlySelectedNode = node

this.SVGTree = SVGTree

And then some code to create some sample data and call it:

$ ->
    data =
        name: "Bob's Car Sales"
        identifier: 1
        selected: false
        childrenOpen: true
        children: [
            {
                name: "Bob's Car Parts"
                identifier: 2
                selected: false
                childrenOpen: false
                children: [
                    {
                        name: "A grandchild"
                        identifier: 14
                        selected: false
                    },
                    {
                        name: "Another grandchild"
                        identifier: 15
                        selected: false
                    }
                ]
            },
            {
                name: "Bob's Auto Service"
                identifier: 3
                selected: true
                childrenOpen: true
                children: [
                    {
                        name: "A grandchild"
                        identifier: 4
                        selected: false
                    },
                    {
                        name: "Another grandchild"
                        identifier: 5
                        selected: false
                    }
                ]
            }
        ]

    tree = new SVGTree
        elementId: 'tree'
        nodeData: data
        boxHeight: 75
        boxWidth: 150
        #horizontalSpace: -50
        indicatorSize: 30
        #defaultNodeFillColor: '#AAA'
        selectedNodeFillColor: '#A89FED'
        #labelFont: { font: "20px Times", opacity: 1 }
        nodeAfterClickHandler: (node) ->
            console.log node
            console.log node.data('identifier'), 'click'
        openCloseAfterClickHandler: (node) ->
            console.log node.data('identifier'), 'open/close'
       
    tree.render()

Note from Matthew in 2026

I can't recommend using this code, or even Raphael or CoffeeScript anymore, but the were big parts of my geek journey. I had lost the original demo page for this code but a freindly LLM and I recreated it, original warts and all. The link below is a fairly faithful recreation of the original code, including a layout but that I have to stifle the impulse to fix. I'll probably do a modern rework of this soon for contrast, but for now, enjoy this little time capsule of my geeky past.

See it in action here