Wednesday, 27 November 2013

Interactive Excel Maps - part 2

Last time I detailed the classes needed for the maps. This time I'll show the code I used to create the maps.

The first thing we need to do is create a new module and set up some global variables. A dictionary object to hold a reference to the map object and ensure that the memory used by it doesn't get collected. A variable to hold the count of maps and a MapEvent object that will switch on and off events when switching sheets and books etc.

Public mcd As New Dictionary
Public mcCount As Integer
Dim clsMapEvent As New MapEvent

The next step is to create the subroutines that will enable and disable the map events. These subroutines are called from the MapEvent class.

Sub Set_All_Maps()
    Dim mc1 As New MapChart
    Dim cs As New VBA.Collection
    Dim chtNumber As Integer
    
    ' Will not work or enable events for active sheet if sheet is a chart sheet'
 
    ' Enable events for all charts embedded on a sheet'
    ' Works for embedded charts on a worksheet or chart sheet'
    If ActiveSheet.ChartObjects.Count > 0 Then
        Dim chtObj As ChartObject
        Dim chtnum As Integer
        Dim sp As Shape
 
        chtnum = 1
        For Each chtObj In ActiveSheet.ChartObjects
            If Left(chtObj.name, 3) = "Map" Then
                Set mc1.MapChart = chtObj.Chart
                Set mc1.srcDataRange = range(Cells(2, 1), Cells(2, 2)).CurrentRegion
                For Each sp In chtObj.Chart.Shapes
                    cs.Add sp
                Next
                Set mc1.reg_shapes = cs
                If Len(chtObj.name) > 3 Then
                    chtNumber = CInt(Mid(chtObj.name, 5, Len(chtObj.name) - 4))
                    mc1.SetScaleLow Workbooks("surveyScience.xlam").Worksheets("MapSheet").Cells(chtNumber, 4).Value, Workbooks("surveyScience.xlam").Worksheets("MapSheet").Cells(chtNumber, 5).Value, Workbooks("surveyScience.xlam").Worksheets("MapSheet").Cells(chtNumber, 6).Value
                    mc1.SetScaleHigh Workbooks("surveyScience.xlam").Worksheets("MapSheet").Cells(chtNumber, 7).Value, Workbooks("surveyScience.xlam").Worksheets("MapSheet").Cells(chtNumber, 8).Value, Workbooks("surveyScience.xlam").Worksheets("MapSheet").Cells(chtNumber, 9).Value
                End If
                mcd.Add mcd.Count + 1, mc1
            End If
        Next
    End If
End Sub
 
Sub Reset_All_Maps()
    ' Disable events for all charts previously enabled'
    Dim chtnum As Integer
    
    On Error Resume Next
    
    For chtnum = 1 To mcd.Count
        Set mcd.Item(chtnum).MapChart = Nothing
    Next
    mcd.RemoveAll
End Sub

Some things need explaining in the above code. Firstly this is all run from an add-in I've called surveyscience.xlam (available here, if you're interested). It contains a sheet called MapSheet that holds details of the maps that I've defined. This is used to update the properties of the map chart. Also, this will only work for embedded charts. Adding code for this should be fairly simple though. I didn't bother as I was using the maps for a dashboard.


The Map Routine

Finally, we need to define the subroutine to create the actual map. This happens in two stages. The first stage loads the map definition to memory, creating the region and region_bit objects along the way. The map created by this macro is based on the details held in the MapSheet worksheet. The variable mapRow passed to the subroutine is used to look up the details from MapSheet.

The second part creates the chart object and draws the map over the chart. The macro is fairly long unfortunately.

Private Sub CreateMap(mapRow As Integer)
    'Maps'
    Dim sdr As range        'Source data range'
    Dim mc1 As New MapChart 'New map chart'
    Dim co As Shape         'Base for map'
    
    'Maxs and mins of map data'
    Dim minX As Double
    Dim minY As Double
    Dim maxX As Double
    Dim maxY As Double

    minX = 1E+200
    minY = 1E+200
    maxX = -1E+200
    maxY = -1E+200
    
    'Load file'
    Dim i As Integer
    Dim j As Integer
    Dim k As Integer
    Dim fileNum As Integer
    Dim mapFSO As FileSystemObject
    Dim mapFile
    Dim LineText As String
    Dim arr
    Dim header
    Dim regName As String
    Dim typ As String
    Dim mapRegs As New VBA.Collection
    Dim temp_reg As Region
    Dim temp_bit As Region_Bit
    Dim temp_point As MapPoint2D
    Dim temp_coord
    Dim defStr1 As String
    Dim wk As Worksheet
    Dim mapRange As range
    
    'Add workbook if none exists'
    If Workbooks.Count = 0 Then
        Workbooks.Add
    End If
    
    'Add worksheet'
    Set wk = Worksheets.Add

    'Get path of map'
    Set mapRange = Workbooks("surveyScience.xlam").Worksheets("MapSheet").Cells(1, 1).CurrentRegion
    Set mapRange = range(mapRange(mapRow, 1), mapRange(mapRow, 9))
    defStr1 = Workbooks("surveyScience.xlam").Worksheets("MapSheet").Cells(mapRow, 2).Text
    
    Set mapFSO = CreateObject("Scripting.FileSystemObject")
    Set mapFile = mapFSO.OpenTextFile(defStr1, 1, False, -1)
    
    'Extract data from file'
    Do While mapFile.AtEndOfStream <> True
        LineText = mapFile.ReadLine
        arr = Split(LineText, ";")
        header = Split(arr(0), ",")
        regName = header(5)
        wk.Cells(mapRegs.Count + 2, 1) = regName
        Set temp_reg = New Region
        temp_reg.setName (regName)
        temp_reg.setColour CInt(header(1)), CInt(header(2)), CInt(header(3))
        If header(0) = "f" Then
            temp_reg.setFill (True)
        Else
            temp_reg.setFill (False)
        End If
            
        arr = Split(arr(1), " ")
        For i = 0 To UBound(arr)
            If arr(i) = "M" Then
                Set temp_bit = New Region_Bit
                typ = "M"
            ElseIf arr(i) = "L" Then
                typ = "L"
            ElseIf arr(i) = "z" Then
                typ = "z"
                temp_reg.addBit temp_bit
                Set temp_bit = Nothing
            Else
                'Split point into x,y'
                temp_coord = Split(arr(i), ",")
                Set temp_point = New MapPoint2D
                temp_point.SetX (temp_coord(0))
                temp_point.SetY (temp_coord(1))
                If typ = "M" Then
                    temp_bit.setStartPoint temp_point
                ElseIf typ = "L" Then
                    temp_bit.addPoint temp_point
                End If
                    
                'Set mins and maxs'
                If temp_coord(0) > maxX Then maxX = temp_coord(0)
                If temp_coord(1) > maxY Then maxY = temp_coord(1)
                If temp_coord(0) < minX Then minX = temp_coord(0)
                If temp_coord(1) < minY Then minY = temp_coord(1)
                    
                Set temp_point = Nothing
            End If
        Next
            
        mapRegs.Add temp_reg
        Set temp_reg = Nothing
    Loop
    mapFile.Close
    
    'Create chart'
    Set co = wk.Shapes.AddChart
    co.Select
    
    'Increase size of chart by 20%'
    co.width = co.width * 1.2
    co.Height = co.Height * 1.2
    
    'Change name so that we can identify chart as a map'
    ActiveChart.Parent.name = "Map_" & mapRow
    
    'Assign active charts as map chart'
    Set mc1.MapChart = ActiveChart
    Set sdr = wk.range(Cells(2, 1), Cells(mapRegs.Count + 1, 2))
    Set mc1.srcDataRange = sdr
    Call mc1.SetScaleLow(mapRange.Cells(1, 4).Value, mapRange.Cells(1, 5).Value, mapRange.Cells(1, 6).Value)
    Call mc1.SetScaleHigh(mapRange.Cells(1, 7).Value, mapRange.Cells(1, 8).Value, mapRange.Cells(1, 9).Value)
    'Set properties of chart'
    ActiveChart.SetSourceData Source:=sdr
    ActiveChart.ChartType = xlColumnClustered
    ActiveChart.PlotBy = xlColumns
    ActiveChart.Legend.Delete
    ActiveChart.HasTitle = False
    ActiveChart.Axes(xlValue).Delete
    ActiveChart.Axes(xlCategory).Delete
    ActiveChart.Axes(xlValue).MajorGridlines.Delete
    
    'Maxs and mins of plot area'
    Dim x1 As Double
    Dim y1 As Double
    Dim x2 As Double
    Dim y2 As Double
    x1 = ActiveChart.PlotArea.Left
    y1 = ActiveChart.PlotArea.Top
    x2 = ActiveChart.PlotArea.Left + ActiveChart.PlotArea.width
    y2 = ActiveChart.PlotArea.Top + ActiveChart.PlotArea.Height
    
    'Background'
    'Chart Area background'
    With ActiveChart.Shapes.BuildFreeform(msoEditingAuto, -6, -6)
        .AddNodes msoSegmentLine, msoEditingAuto, -6, ActiveChart.ChartArea.Height
        .AddNodes msoSegmentLine, msoEditingAuto, ActiveChart.ChartArea.width, ActiveChart.ChartArea.Height
        .AddNodes msoSegmentLine, msoEditingAuto, ActiveChart.ChartArea.width, -6
        .AddNodes msoSegmentLine, msoEditingAuto, -6, -6
        .ConvertToShape.name = "Background"
    End With
    
    'Calculate scaling factors'
    Dim mx As Double
    Dim cx As Double
    Dim my As Double
    Dim cy As Double
    Dim dA As Double
    Dim rA As Double
    Dim ex As Double
    
    mx = (x2 - x1) / (maxX - minX)
    my = (y2 - y1) / (maxY - minY)
    cx = x1 - mx * minX
    cy = y1 - my * minY
    
    'Keep aspect the same'
    dA = Abs((y2 - y1) / (x2 - x1))
    rA = Abs((maxY - minY) / (maxX - minX))
    If (dA > rA) Then
        ex = (maxY - minY) * (dA / rA - 1)
        mx = (x2 - x1) / (maxX - minX)
        my = (y2 - y1) / ((maxY + ex / 2) - (minY - ex / 2))
        cx = x1 - mx * minX
        cy = y1 - my * (minY - ex / 2)
    ElseIf (dA < rA) Then
        ex = (maxX - minX) * (rA / dA - 1)
        mx = (x2 - x1) / ((maxX + ex / 2) - (minX - ex / 2))
        my = (y2 - y1) / (maxY - minY)
        cx = x1 - mx * (minX - ex / 2)
        cy = y1 - my * minY
    Else
        mx = 1
        my = 1
        cx = 0
        cy = 0
    End If
    
    'Draw parts of map'
    Dim reg_shape
    Dim t As Shape
    Dim rs As New VBA.Collection
    
    'Cycle through regions'
    For i = 1 To mapRegs.Count
        'Cycle through bits'
        For j = 1 To mapRegs.Item(i).getNumBits
            Set temp_bit = mapRegs.Item(i).getBit(j)
            'Create new shape'
            Set reg_shape = ActiveChart.Shapes.BuildFreeform(msoEditingAuto, temp_bit.getStartPoint().GetX() * mx + cx, temp_bit.getStartPoint().GetY() * my + cy)
            'Cycle through points'
            For k = 1 To mapRegs.Item(i).getBit(j).getNumPoints
                'Add point to shape'
                reg_shape.AddNodes msoSegmentLine, msoEditingAuto, temp_bit.getPoint(k).GetX() * mx + cx, temp_bit.getPoint(k).GetY() * my + cy
            Next
            'Convert to shape'
            If mapRegs.Item(i).getFill() = True Then
                Set t = reg_shape.ConvertToShape
                With t
                    .name = mapRegs.Item(i).getName
                    .fill.ForeColor.RGB = mapRegs.Item(i).getColour2()
                    .Line.ForeColor.RGB = RGB(0, 0, 0)
                    .Line.Weight = 0.25
                    
                End With
                rs.Add t
            End If
            
            Set reg_shape = Nothing
        Next
    Next
    
    Set mc1.reg_shapes = rs
    mcCount = mcCount + 1
    mcd.Add mcCount, mc1
End Sub

Conclusion

And there it is, a way to create reasonable and extensible maps in Excel.

Presumably you could combine the two parts of the macro and create the shapes directly from the file. It might be easier. However, splitting the code makes it easier to change the subroutine to load in different map formats.

One limitation that I've found in Excel 2013 is that the shapes seem to default to a mitre join. This makes the maps look horrendous. There is no way to programmatically change the joins to round. You have to manually create a shape, change the join type, set the shape as the default type and then run the macro. Odd.

As usual, comments are welcome.

No comments:

Post a Comment