DEV Community

Kristian Pedersen
Kristian Pedersen

Posted on

#30daysofelm Day 21: Planet list -> SVG drawings

The planets and their orbits

Demo: https://ellie-app.com/c25wRvWCppXa1

This is day 21 of my 30 day Elm challenge

Today I had some fun with elm/svg, using hardcoded data to draw some fake orbits and planets.

The reason I got into JavaScript in the first place was Daniel Shiffman's visualizations of mathematics, fractals, physics and animations. You're guaranteed to find something cool in his Coding Challenge playlist.

In other words, I had a great time drawing circles and lines with code again. :)

1. About the project

I want to draw something, based on a data source.

Orbits: Transparent circles with black strokes. radius = index * someNumber

Planets: Filled circles. Index lets us calculate X and Y coordinates, by doing something like this:

x = middleOfScreen + (sin angle * (index * radius))
y = middleOfScreen + (cos angle * (index * radius))
Enter fullscreen mode Exit fullscreen mode

2. Imports, Model and Msg

module Main exposing (main)

import Browser
import Browser.Dom exposing (Viewport)
import Html exposing (Html)
import Html.Attributes exposing (attribute)
import Svg exposing (..)
import Svg.Attributes exposing (..)
import Task


type alias Model =
    { width : Float, height : Float }


initialModel : Model
initialModel =
    { width = 0, height = 0 }


type Msg
    = NoOp
    | ReceivedViewport Viewport
Enter fullscreen mode Exit fullscreen mode

Viewport and Task are used to get the browser's dimensions. Looking at my code from day 8, I'm wondering if just getting those from JavaScript using innerWidth and innerHeight would be a cleaner solution. Maybe it has some drawbacks.

Yesterday, I used Task to get the browser size, but I also implemented a resize solution. Today, I decided not to bother with that, just to keep it simple.

NoOp is just "don't do any commands", while ReceivedViewport will be used to update the model.

3. The data

type alias Planet =
    { name : String
    , size : Int
    , color : String
    }


type alias WindowDimensions =
    { width : Float
    , height : Float
    , centerX : Float
    , centerY : Float
    }


hardcodedPlanets : List Planet
hardcodedPlanets =
    [ { name = "Mercury", size = 8, color = "gray" }
    , { name = "Venus", size = 24, color = "yellow" }
    , { name = "Earth", size = 32, color = "blue" }
    , { name = "Mars", size = 16, color = "red" }
    , { name = "Saturn", size = 56, color = "orange" }
    , { name = "Jupiter", size = 64, color = "gold" }
    , { name = "Uranus", size = 48, color = "lightblue" }
    , { name = "Neptune", size = 40, color = "blue" }
    , { name = "Pluto", size = 8, color = "beige" }
    ]
Enter fullscreen mode Exit fullscreen mode

I'm appreciating type aliases and the resulting type annotations more and more.

Seeing actual words instead of just Float and List String is really nice, and we'll see some examples of this later in the code.

4. Displaying individual planets and orbits

viewOrbit : WindowDimensions -> Float -> Html msg
viewOrbit window index =
    circle
        [ cx (window.centerX |> String.fromFloat)
        , cy (window.centerY |> String.fromFloat)
        , r ((index + 1) * 50 |> String.fromFloat)
        , fill "none"
        , stroke "black"
        ]
        []


viewPlanet : WindowDimensions -> Float -> Planet -> Html msg
viewPlanet window index planet =
    circle
        [ cx ((window.centerX + sin index * ((index + 1) * 50)) |> String.fromFloat)
        , cy ((window.centerY + cos index * ((index + 1) * 50)) |> String.fromFloat)
        , r (planet.size |> String.fromInt)
        , fill planet.color
        , stroke "black"
        ]
        []
Enter fullscreen mode Exit fullscreen mode

Looking at this code, the index should have been part of the Planet instead. Oh well.

Because we're dealing with SVGs, the numbers need to be converted to strings. It's mildly annoying, but I can live with it.

As we can see, the orbits' radiuses will increase by the same number of pixels. It's not accurate, but visualizing the real orbits would have been silly.

The planet positions are calculated this way: centerOfScreen + (angle * radius).

Using the index for angles is a bit silly, but I like doing silly things sometimes. Later on, my goal is to get accurate angles from my Python backend.

5. Displaying multiple planets and orbits

viewOrbits : WindowDimensions -> List (Svg msg)
viewOrbits window =
    hardcodedPlanets
        |> List.indexedMap (\index planet -> viewOrbit window (toFloat index))


viewPlanets : WindowDimensions -> List (Svg msg)
viewPlanets window =
    hardcodedPlanets
        |> List.indexedMap (\index planet -> viewPlanet window (toFloat index) planet)
Enter fullscreen mode Exit fullscreen mode

These are almost identical, but I figured there might be scenarios where you might wish to toggle just one of them.

The window is just being passed to the individual viewOrbit and viewPlanet functions.

List.indexedMap gives us access to both the index and element, just like JavaScript does natively: array.map((e, i) => ...).

6. View

view : Model -> Html msg
view model =
    let
        window =
            { width = model.width
            , height = model.height
            , centerX = model.width / 2
            , centerY = model.height / 2
            }
    in
    svg
        [ width (window.width |> String.fromFloat)
        , height (window.height |> String.fromFloat)
        , viewBox
            ("0 0 "
                ++ (window.width |> String.fromFloat)
                ++ " "
                ++ (window.height |> String.fromFloat)
            )
        ]
    <|
        viewOrbits window
            ++ viewPlanets window
Enter fullscreen mode Exit fullscreen mode

The let statement isn't strictly necessary, but I just think window.width sounds a lot better than model.width.

There's a lot of String.fromFloat going on here. On one hand, I want to write a very short function to make things less verbose, but then everyone else would have to learn about my spcial snowflake function.

The ++ works for converting multiple lists into one, which is pretty cool. I almost created two separate SVG elements here!

7. Main function with what's needed to get the browser width and height

main : Program () Model Msg
main =
    let
        handleResult v =
            case v of
                Err err ->
                    NoOp

                Ok vp ->
                    ReceivedViewport vp
    in
    Browser.element
        { init = \_ -> ( initialModel, Task.attempt handleResult Browser.Dom.getViewport )
        , view = view
        , update = update
        , subscriptions = \_ -> Sub.none
        }
Enter fullscreen mode Exit fullscreen mode

If your read the let statement first, this may look confusing. Things look better if we start with Browser.element.

Init equals an anonymous function which returns a tuple, consisting of:

  1. The inital model
  2. A function called Task.attempt, which sends a message handleResult, depending on the outcome of Browser.Dom.getViewport

If you then read the let statement, things make a bit more sense. NoOp or ReceivedViewport get sent to the update function, I believe.

8. Conclusion

Drawing stuff with code is still fun.

It was good to have a more relaxing kind of project today. The last days have been challenging.

I went for two walks today, which was a very good idea.

Top comments (0)