Elm logo
elm
examples

elm-visualization

/

examples

/

Tidy Tree

Edit on Ellie

Tidy Tree

Lays out a tree diagram using the Tidy algorithm. We scale the icons for the nodes proportionally to a quantitative dimension.

Since the Tree is quite dense and complex, we add a zoom behavior to allow the use to explore the leaf nodes more easily. Clicking on a non-lead node zooms the tree to its subtree.

module TidyTree exposing (main)


import Browser
import Color
import Curve
import Hierarchy
import List.Extra
import Path
import Shape
import Tree exposing (Tree)
import TypedSvg exposing (g, rect, svg)
import TypedSvg.Attributes exposing (dy, fill, href, id, pointerEvents, stroke, style, textAnchor, transform, viewBox)
import TypedSvg.Attributes.InPx exposing (fontSize, height, width, x, y)
import TypedSvg.Core exposing (Svg)
import TypedSvg.Events
import TypedSvg.Types exposing (AnchorAlignment(..), ClipPath(..), Paint(..), Transform(..), em)
import Zoom exposing (Zoom)


w : Float
w =
    990


h : Float
h =
    504


padding : Float
padding =
    30


layedOut : Tree Datum
layedOut =
    Hierarchy.tidy
        [ Hierarchy.nodeSize
            (\{ kind, size } ->
                case kind of
                    File ->
                        ( sqrt size / 1.1, sqrt size * 1.1 )

                    Directory ->
                        ( sqrt size * 1.05, sqrt size / 1.05 )
            )
        , Hierarchy.parentChildMargin 900
        , Hierarchy.peerMargin 60
        , Hierarchy.size (w - padding * 2) (h - padding * 2)
        ]
        tree
        |> Tree.sumUp
            (\n ->
                { x = n.x
                , y = n.y
                , width = n.width
                , height = n.height
                , node = n.node
                , bbox = { x = n.x, y = n.y, width = n.width, height = n.height }
                }
            )
            (\n c ->
                { x = n.x
                , y = n.y
                , width = n.width
                , height = n.height
                , node = n.node
                , bbox = List.foldl (\item t -> maxBBoxes item.bbox t) { x = n.x, y = n.y, width = n.width, height = n.height } c
                }
            )


maxBBoxes :
    { x : Float, y : Float, width : Float, height : Float }
    -> { x : Float, y : Float, width : Float, height : Float }
    -> { x : Float, y : Float, width : Float, height : Float }
maxBBoxes a b =
    let
        x =
            min a.x b.x

        y =
            min a.y b.y
    in
    { x = x
    , y = y
    , width = max (a.x + a.width) (b.x + b.width) - x
    , height = max (a.y + a.height) (b.y + b.height) - y
    }


type alias Datum =
    { x : Float
    , y : Float
    , width : Float
    , height : Float
    , node : { size : Float, name : String, kind : Kind }
    , bbox : { x : Float, y : Float, width : Float, height : Float }
    }


type Msg
    = ZoomMsg Zoom.OnZoom
    | Click Datum


main : Program () Zoom Msg
main =
    Browser.element
        { init =
            \() ->
                ( Zoom.init { width = w, height = h }
                    |> Zoom.scaleExtent 1 100
                    |> Zoom.translateExtent ( ( 0, 0 ), ( w, h ) )
                , Cmd.none
                )
        , update =
            \msg model ->
                case msg of
                    ZoomMsg m ->
                        ( Zoom.update m model, Cmd.none )

                    Click t ->
                        ( performZoom t model, Cmd.none )
        , subscriptions = \model -> Zoom.subscriptions model ZoomMsg
        , view = view
        }


performZoom : Datum -> Zoom -> Zoom
performZoom t =
    let
        p =
            ( t.x + t.width / 2, t.y + t.height / 2 )

        xScale =
            (w - 2 * padding) / t.bbox.width

        yScale =
            (h - 2 * padding) / t.bbox.height

        scale =
            min xScale yScale

        tx =
            (padding + t.bbox.x) * -scale

        ty =
            (padding + t.bbox.y) * -scale

        translate =
            if xScale > yScale then
                { x = w / 2 - scale * t.bbox.width / 2 + tx, y = padding + ty }

            else
                { x = padding + tx, y = h / 2 - scale * t.bbox.height / 2 + ty }
    in
    Zoom.setTransform (Zoom.animatedAround p) { scale = scale, translate = translate }


view : Zoom -> Svg Msg
view zoom =
    svg [ viewBox 0 0 w h ]
        [ rect
            (width w
                :: height h
                :: fill (Paint (Color.rgba 0 0 0 0))
                :: Zoom.events zoom ZoomMsg
            )
            []
        , g [ Zoom.transform zoom ]
            [ g [ transform [ Translate padding padding ] ]
                [ layedOut
                    |> Tree.links
                    |> List.map
                        (\( from, to ) ->
                            Shape.bumpYCurve
                                [ ( from.x + from.width / 2, from.y + from.height )
                                , ( to.x + to.width / 2, to.y + to.height * 0.1 )
                                ]
                        )
                    |> (\p ->
                            Path.element p
                                [ fill PaintNone
                                , stroke (Paint (Color.rgb 0.3 0.3 0.3))
                                , style "vector-effect: non-scaling-stroke"
                                , pointerEvents "none"
                                ]
                       )
                , layedOut
                    |> Tree.toList
                    |> List.map label
                    |> g []
                ]
            ]
        ]


label : Datum -> Svg Msg
label item =
    case item.node.kind of
        File ->
            let
                corner =
                    min item.width item.height * 0.2
            in
            g [ transform [ Translate item.x item.y ] ]
                [ Path.element
                    [ Curve.linearClosed
                        [ ( 0, item.height )
                        , ( 0, 0 )
                        , ( item.width - corner, 0 )
                        , ( item.width, corner )
                        , ( item.width, item.height )
                        ]
                    , Curve.linear
                        [ ( item.width - corner, 0 )
                        , ( item.width - corner, corner )
                        , ( item.width, corner )
                        ]
                    ]
                    [ fill (Paint (Color.rgb 1 1 0.8))
                    , stroke (Paint Color.black)
                    , style "vector-effect: non-scaling-stroke"
                    , id ("path-" ++ item.node.name)
                    ]
                , TypedSvg.title [] [ TypedSvg.Core.text (item.node.name ++ " - " ++ String.fromFloat item.node.size ++ " loc") ]
                , TypedSvg.clipPath [ id ("clip-" ++ item.node.name) ]
                    [ TypedSvg.use [ href ("#path-" ++ item.node.name) ] []
                    ]
                , item.node.name
                    |> String.split "."
                    |> List.reverse
                    |> List.head
                    |> Maybe.withDefault item.node.name
                    |> String.foldl
                        (\c ( word, soFar ) ->
                            if Char.isUpper c then
                                ( [ c ]
                                , case word of
                                    [] ->
                                        soFar

                                    _ ->
                                        String.fromList (List.reverse word) :: soFar
                                )

                            else
                                ( c :: word, soFar )
                        )
                        ( [], [] )
                    |> (\( word, soFar ) -> String.fromList (List.reverse word) :: soFar)
                    |> List.reverse
                    |> List.indexedMap
                        (\i st ->
                            TypedSvg.tspan
                                [ x (item.width * 0.05)
                                , TypedSvg.Attributes.y (em (2 + toFloat i * 0.9))
                                ]
                                [ TypedSvg.Core.text st ]
                        )
                    |> TypedSvg.text_
                        [ fontSize (sqrt item.node.size / 130)
                        , pointerEvents "none"
                        , TypedSvg.Attributes.clipPath (ClipPathFunc ("url(#clip-" ++ item.node.name ++ ")"))
                        ]
                ]

        Directory ->
            g []
                [ Path.element
                    [ Curve.linearClosed
                        [ ( item.x, item.y + item.height )
                        , ( item.x, item.y + item.height * 0.1 )
                        , ( item.x + item.width * 0.05, item.y )
                        , ( item.x + item.width * 0.4, item.y )
                        , ( item.x + item.width * 0.45, item.y + item.height * 0.1 )
                        , ( item.x + item.width, item.y + item.height * 0.1 )
                        , ( item.x + item.width, item.y + item.height )
                        ]
                    ]
                    [ fill (Paint (Color.rgb 0.8 0.9 1))
                    , stroke (Paint Color.black)
                    , style "vector-effect: non-scaling-stroke"
                    , TypedSvg.Events.onClick (Click item)
                    ]
                , TypedSvg.title [] [ TypedSvg.Core.text (item.node.name ++ " - " ++ String.fromFloat item.node.size ++ " loc") ]
                , TypedSvg.text_
                    [ textAnchor AnchorMiddle
                    , fontSize (sqrt item.node.size / 110)
                    , pointerEvents "none"
                    , x (item.x + item.width / 2)
                    , y (item.y + item.height / 2)
                    , dy (em 0.3)
                    ]
                    [ TypedSvg.Core.text (String.split "." item.node.name |> List.Extra.last |> Maybe.withDefault "") ]
                ]


type Kind
    = File
    | Directory


tree : Tree { name : String, kind : Kind, size : Float }
tree =
    Tree.stratifyWithPath
        { path = \item -> String.split "." item.name
        , createMissingNode = \path -> { name = String.join "." path, size = 0 }
        }
        data
        |> Result.withDefault (Tree.singleton { name = "flare", size = 0 })
        |> Tree.sumUp (\n -> { name = n.name, size = n.size, kind = File })
            (\node children ->
                { name = node.name, size = List.sum (List.map .size children), kind = Directory }
            )
        |> Tree.sortBy (Tree.label >> .name)


data : List { name : String, size : Float }
data =
    [ { name = "flare.analytics.cluster.AgglomerativeCluster", size = 3938 }
    , { name = "flare.analytics.cluster.CommunityStructure", size = 3812 }
    , { name = "flare.analytics.cluster.HierarchicalCluster", size = 6714 }
    , { name = "flare.analytics.cluster.MergeEdge", size = 743 }
    , { name = "flare.analytics.graph.BetweennessCentrality", size = 3534 }
    , { name = "flare.analytics.graph.LinkDistance", size = 5731 }
    , { name = "flare.analytics.graph.MaxFlowMinCut", size = 7840 }
    , { name = "flare.analytics.graph.ShortestPaths", size = 5914 }
    , { name = "flare.analytics.graph.SpanningTree", size = 3416 }
    , { name = "flare.analytics.optimization.AspectRatioBanker", size = 7074 }
    , { name = "flare.animate.Easing", size = 17010 }
    , { name = "flare.animate.FunctionSequence", size = 5842 }
    , { name = "flare.animate.interpolate.ArrayInterpolator", size = 1983 }
    , { name = "flare.animate.interpolate.ColorInterpolator", size = 2047 }
    , { name = "flare.animate.interpolate.DateInterpolator", size = 1375 }
    , { name = "flare.animate.interpolate.Interpolator", size = 8746 }
    , { name = "flare.animate.interpolate.MatrixInterpolator", size = 2202 }
    , { name = "flare.animate.interpolate.NumberInterpolator", size = 1382 }
    , { name = "flare.animate.interpolate.ObjectInterpolator", size = 1629 }
    , { name = "flare.animate.interpolate.PointInterpolator", size = 1675 }
    , { name = "flare.animate.interpolate.RectangleInterpolator", size = 2042 }
    , { name = "flare.animate.ISchedulable", size = 1041 }
    , { name = "flare.animate.Parallel", size = 5176 }
    , { name = "flare.animate.Pause", size = 449 }
    , { name = "flare.animate.Scheduler", size = 5593 }
    , { name = "flare.animate.Sequence", size = 5534 }
    , { name = "flare.animate.Transition", size = 9201 }
    , { name = "flare.animate.Transitioner", size = 19975 }
    , { name = "flare.animate.TransitionEvent", size = 1116 }
    , { name = "flare.animate.Tween", size = 6006 }
    , { name = "flare.data.converters.Converters", size = 721 }
    , { name = "flare.data.converters.DelimitedTextConverter", size = 4294 }
    , { name = "flare.data.converters.GraphMLConverter", size = 9800 }
    , { name = "flare.data.converters.IDataConverter", size = 1314 }
    , { name = "flare.data.converters.JSONConverter", size = 2220 }
    , { name = "flare.data.DataField", size = 1759 }
    , { name = "flare.data.DataSchema", size = 2165 }
    , { name = "flare.data.DataSet", size = 586 }
    , { name = "flare.data.DataSource", size = 3331 }
    , { name = "flare.data.DataTable", size = 772 }
    , { name = "flare.data.DataUtil", size = 3322 }
    , { name = "flare.display.DirtySprite", size = 8833 }
    , { name = "flare.display.LineSprite", size = 1732 }
    , { name = "flare.display.RectSprite", size = 3623 }
    , { name = "flare.display.TextSprite", size = 10066 }
    , { name = "flare.flex.FlareVis", size = 4116 }
    , { name = "flare.physics.DragForce", size = 1082 }
    , { name = "flare.physics.GravityForce", size = 1336 }
    , { name = "flare.physics.IForce", size = 319 }
    , { name = "flare.physics.NBodyForce", size = 10498 }
    , { name = "flare.physics.Particle", size = 2822 }
    , { name = "flare.physics.Simulation", size = 9983 }
    , { name = "flare.physics.Spring", size = 2213 }
    , { name = "flare.physics.SpringForce", size = 1681 }
    , { name = "flare.query.AggregateExpression", size = 1616 }
    , { name = "flare.query.And", size = 1027 }
    , { name = "flare.query.Arithmetic", size = 3891 }
    , { name = "flare.query.Average", size = 891 }
    , { name = "flare.query.BinaryExpression", size = 2893 }
    , { name = "flare.query.Comparison", size = 5103 }
    , { name = "flare.query.CompositeExpression", size = 3677 }
    , { name = "flare.query.Count", size = 781 }
    , { name = "flare.query.DateUtil", size = 4141 }
    , { name = "flare.query.Distinct", size = 933 }
    , { name = "flare.query.Expression", size = 5130 }
    , { name = "flare.query.ExpressionIterator", size = 3617 }
    , { name = "flare.query.Fn", size = 3240 }
    , { name = "flare.query.If", size = 2732 }
    , { name = "flare.query.IsA", size = 2039 }
    , { name = "flare.query.Literal", size = 1214 }
    , { name = "flare.query.Match", size = 3748 }
    , { name = "flare.query.Maximum", size = 843 }
    , { name = "flare.query.methods.add", size = 593 }
    , { name = "flare.query.methods.and", size = 330 }
    , { name = "flare.query.methods.average", size = 287 }
    , { name = "flare.query.methods.count", size = 277 }
    , { name = "flare.query.methods.distinct", size = 292 }
    , { name = "flare.query.methods.div", size = 595 }
    , { name = "flare.query.methods.eq", size = 594 }
    , { name = "flare.query.methods.fn", size = 460 }
    , { name = "flare.query.methods.gt", size = 603 }
    , { name = "flare.query.methods.gte", size = 625 }
    , { name = "flare.query.methods.iff", size = 748 }
    , { name = "flare.query.methods.isa", size = 461 }
    , { name = "flare.query.methods.lt", size = 597 }
    , { name = "flare.query.methods.lte", size = 619 }
    , { name = "flare.query.methods.max", size = 283 }
    , { name = "flare.query.methods.min", size = 283 }
    , { name = "flare.query.methods.mod", size = 591 }
    , { name = "flare.query.methods.mul", size = 603 }
    , { name = "flare.query.methods.neq", size = 599 }
    , { name = "flare.query.methods.not", size = 386 }
    , { name = "flare.query.methods.or", size = 323 }
    , { name = "flare.query.methods.orderby", size = 307 }
    , { name = "flare.query.methods.range", size = 772 }
    , { name = "flare.query.methods.select", size = 296 }
    , { name = "flare.query.methods.stddev", size = 363 }
    , { name = "flare.query.methods.sub", size = 600 }
    , { name = "flare.query.methods.sum", size = 280 }
    , { name = "flare.query.methods.update", size = 307 }
    , { name = "flare.query.methods.variance", size = 335 }
    , { name = "flare.query.methods.where", size = 299 }
    , { name = "flare.query.methods.xor", size = 354 }
    , { name = "flare.query.methods._", size = 264 }
    , { name = "flare.query.Minimum", size = 843 }
    , { name = "flare.query.Not", size = 1554 }
    , { name = "flare.query.Or", size = 970 }
    , { name = "flare.query.Query", size = 13896 }
    , { name = "flare.query.Range", size = 1594 }
    , { name = "flare.query.StringUtil", size = 4130 }
    , { name = "flare.query.Sum", size = 791 }
    , { name = "flare.query.Variable", size = 1124 }
    , { name = "flare.query.Variance", size = 1876 }
    , { name = "flare.query.Xor", size = 1101 }
    , { name = "flare.scale.IScaleMap", size = 2105 }
    , { name = "flare.scale.LinearScale", size = 1316 }
    , { name = "flare.scale.LogScale", size = 3151 }
    , { name = "flare.scale.OrdinalScale", size = 3770 }
    , { name = "flare.scale.QuantileScale", size = 2435 }
    , { name = "flare.scale.QuantitativeScale", size = 4839 }
    , { name = "flare.scale.RootScale", size = 1756 }
    , { name = "flare.scale.Scale", size = 4268 }
    , { name = "flare.scale.ScaleType", size = 1821 }
    , { name = "flare.scale.TimeScale", size = 5833 }
    , { name = "flare.util.Arrays", size = 8258 }
    , { name = "flare.util.Colors", size = 10001 }
    , { name = "flare.util.Dates", size = 8217 }
    , { name = "flare.util.Displays", size = 12555 }
    , { name = "flare.util.Filter", size = 2324 }
    , { name = "flare.util.Geometry", size = 10993 }
    , { name = "flare.util.heap.FibonacciHeap", size = 9354 }
    , { name = "flare.util.heap.HeapNode", size = 1233 }
    , { name = "flare.util.IEvaluable", size = 335 }
    , { name = "flare.util.IPredicate", size = 383 }
    , { name = "flare.util.IValueProxy", size = 874 }
    , { name = "flare.util.math.DenseMatrix", size = 3165 }
    , { name = "flare.util.math.IMatrix", size = 2815 }
    , { name = "flare.util.math.SparseMatrix", size = 3366 }
    , { name = "flare.util.Maths", size = 17705 }
    , { name = "flare.util.Orientation", size = 1486 }
    , { name = "flare.util.palette.ColorPalette", size = 6367 }
    , { name = "flare.util.palette.Palette", size = 1229 }
    , { name = "flare.util.palette.ShapePalette", size = 2059 }
    , { name = "flare.util.palette.SizePalette", size = 2291 }
    , { name = "flare.util.Property", size = 5559 }
    , { name = "flare.util.Shapes", size = 19118 }
    , { name = "flare.util.Sort", size = 6887 }
    , { name = "flare.util.Stats", size = 6557 }
    , { name = "flare.util.Strings", size = 22026 }
    , { name = "flare.vis.axis.Axes", size = 1302 }
    , { name = "flare.vis.axis.Axis", size = 24593 }
    , { name = "flare.vis.axis.AxisGridLine", size = 652 }
    , { name = "flare.vis.axis.AxisLabel", size = 636 }
    , { name = "flare.vis.axis.CartesianAxes", size = 6703 }
    , { name = "flare.vis.controls.AnchorControl", size = 2138 }
    , { name = "flare.vis.controls.ClickControl", size = 3824 }
    , { name = "flare.vis.controls.Control", size = 1353 }
    , { name = "flare.vis.controls.ControlList", size = 4665 }
    , { name = "flare.vis.controls.DragControl", size = 2649 }
    , { name = "flare.vis.controls.ExpandControl", size = 2832 }
    , { name = "flare.vis.controls.HoverControl", size = 4896 }
    , { name = "flare.vis.controls.IControl", size = 763 }
    , { name = "flare.vis.controls.PanZoomControl", size = 5222 }
    , { name = "flare.vis.controls.SelectionControl", size = 7862 }
    , { name = "flare.vis.controls.TooltipControl", size = 8435 }
    , { name = "flare.vis.data.Data", size = 20544 }
    , { name = "flare.vis.data.DataList", size = 19788 }
    , { name = "flare.vis.data.DataSprite", size = 10349 }
    , { name = "flare.vis.data.EdgeSprite", size = 3301 }
    , { name = "flare.vis.data.NodeSprite", size = 19382 }
    , { name = "flare.vis.data.render.ArrowType", size = 698 }
    , { name = "flare.vis.data.render.EdgeRenderer", size = 5569 }
    , { name = "flare.vis.data.render.IRenderer", size = 353 }
    , { name = "flare.vis.data.render.ShapeRenderer", size = 2247 }
    , { name = "flare.vis.data.ScaleBinding", size = 11275 }
    , { name = "flare.vis.data.Tree", size = 7147 }
    , { name = "flare.vis.data.TreeBuilder", size = 9930 }
    , { name = "flare.vis.events.DataEvent", size = 2313 }
    , { name = "flare.vis.events.SelectionEvent", size = 1880 }
    , { name = "flare.vis.events.TooltipEvent", size = 1701 }
    , { name = "flare.vis.events.VisualizationEvent", size = 1117 }
    , { name = "flare.vis.legend.Legend", size = 20859 }
    , { name = "flare.vis.legend.LegendItem", size = 4614 }
    , { name = "flare.vis.legend.LegendRange", size = 10530 }
    , { name = "flare.vis.operator.distortion.BifocalDistortion", size = 4461 }
    , { name = "flare.vis.operator.distortion.Distortion", size = 6314 }
    , { name = "flare.vis.operator.distortion.FisheyeDistortion", size = 3444 }
    , { name = "flare.vis.operator.encoder.ColorEncoder", size = 3179 }
    , { name = "flare.vis.operator.encoder.Encoder", size = 4060 }
    , { name = "flare.vis.operator.encoder.PropertyEncoder", size = 4138 }
    , { name = "flare.vis.operator.encoder.ShapeEncoder", size = 1690 }
    , { name = "flare.vis.operator.encoder.SizeEncoder", size = 1830 }
    , { name = "flare.vis.operator.filter.FisheyeTreeFilter", size = 5219 }
    , { name = "flare.vis.operator.filter.GraphDistanceFilter", size = 3165 }
    , { name = "flare.vis.operator.filter.VisibilityFilter", size = 3509 }
    , { name = "flare.vis.operator.IOperator", size = 1286 }
    , { name = "flare.vis.operator.label.Labeler", size = 9956 }
    , { name = "flare.vis.operator.label.RadialLabeler", size = 3899 }
    , { name = "flare.vis.operator.label.StackedAreaLabeler", size = 3202 }
    , { name = "flare.vis.operator.layout.AxisLayout", size = 6725 }
    , { name = "flare.vis.operator.layout.BundledEdgeRouter", size = 3727 }
    , { name = "flare.vis.operator.layout.CircleLayout", size = 9317 }
    , { name = "flare.vis.operator.layout.CirclePackingLayout", size = 12003 }
    , { name = "flare.vis.operator.layout.DendrogramLayout", size = 4853 }
    , { name = "flare.vis.operator.layout.ForceDirectedLayout", size = 8411 }
    , { name = "flare.vis.operator.layout.IcicleTreeLayout", size = 4864 }
    , { name = "flare.vis.operator.layout.IndentedTreeLayout", size = 3174 }
    , { name = "flare.vis.operator.layout.Layout", size = 7881 }
    , { name = "flare.vis.operator.layout.NodeLinkTreeLayout", size = 12870 }
    , { name = "flare.vis.operator.layout.PieLayout", size = 2728 }
    , { name = "flare.vis.operator.layout.RadialTreeLayout", size = 12348 }
    , { name = "flare.vis.operator.layout.RandomLayout", size = 870 }
    , { name = "flare.vis.operator.layout.StackedAreaLayout", size = 9121 }
    , { name = "flare.vis.operator.layout.TreeMapLayout", size = 9191 }
    , { name = "flare.vis.operator.Operator", size = 2490 }
    , { name = "flare.vis.operator.OperatorList", size = 5248 }
    , { name = "flare.vis.operator.OperatorSequence", size = 4190 }
    , { name = "flare.vis.operator.OperatorSwitch", size = 2581 }
    , { name = "flare.vis.operator.SortOperator", size = 2023 }
    , { name = "flare.vis.Visualization", size = 16540 }
    ]