The Orrery
11 Dec 2016Hello! Sorry for taking so long to write another post. I’ve been really quite busy looking for a new place to live and a new office to work in, you see. Anyway, instead of adding another post in my introductory theme, I thought I’d show you how this Orrery works! It’s written in Elm, which isn’t supported by GitHub’s highlighter yet, so I’ve modified it a little (my excuse for why the code blocks look a bit naff).
Anyway, I hope it’s useful to someone - we’ll talk about Elm’s successes and my failings, and everything should hopefully be clear enough. If anything doesn’t make sense, though, feel free to send me a Tweet and I’ll do my best to clarify!
Preamble
module Main exposing (..)
import AnimationFrame
import Html
import Http
import Json.Decode as Decode
import Svg
import Svg.Attributes exposing (..)
import Time exposing (inMinutes, Time)
This project uses a few dependencies. To anyone who’s written any amount of Elm before, the only stranger is AnimationFrame
, from the elm-lang/animation-frame
package. This lets us subscribe to the browser’s RAF API. Everything else should be fairly obvious: Html
/ Svg
for our view, Http
/ Json.Decode
for the AJAX request, and Time
for our orbit.
main =
Html.program
{ init = init
, subscriptions = \_ -> AnimationFrame.times Tick
, update = update
, view = view
}
We can’t use the beginnerProgram
because of the need for both subscriptions and commands, so we opt for the next easiest thing. Perhaps against convention, I tend to put one-line declarations (e.g. subscriptions
) directly in the Html.program
call - it looks prettier to me… Sorry, Evan!
The Model
type alias System =
{ colour : String
, orbit : Float
, radius : Float
, speed : Float
, moons : Moons
}
The model for this visualisation is recursive: a system is a body and its moons, all of which are systems themselves. However, this means we have an infinitely-nesting type, so we have to use sidestep this with a new type Moons
to get a solid alias.
type Moons
= Moons (List System)
Of course, aside from a little extra destructuring, this doesn’t change the capability of the type at all.
type alias Model =
{ time : Time
, system : Maybe System
}
The model is then just the current Time
(from our subscription) and maybe a System
. If the AJAX response hasn’t come back yet, or an error occurred during the lifecycle, our System
is Nothing
, and we can show something other than an empty sky.
init =
( { time = 0, system = Nothing }
, Http.send Register
<< Http.get "./test.json"
<| planetify
)
The initial command is the AJAX request: when this completes, the model will hold the returned JSON or lack thereof. I think this whole Cmd
approach is really neat: our IO actions end up handled in exactly the same way as our user interactions.
JSON
planetify : Decode.Decoder System
planetify =
Decode.map5 System
(Decode.field "colour" Decode.string)
(Decode.field "orbit" Decode.float )
(Decode.field "radius" Decode.float )
(Decode.field "speed" Decode.float )
(Decode.field "moons" << Decode.map Moons
<< Decode.lazy
<| \_ -> Decode.list planetify)
When we get the AJAX response, we need to map it into a data type: in our case, the System
type. In Elm, we do this with a Json.Decoder
. Here, we use a decoder that can recursively deconstruct the JSON to match our type. It also validates our JSON response at the same time!
We need to use the Decode.map
and Decode.lazy
functions to avoid that pesky infinite type: the Moons
type will be populated by a second map that occurs lazily.
Update
type Msg
= Tick Time
| Register (Result Http.Error System)
There are really only two things that happen in this visualisation: the Time
updates (for the next animation frame), and the AJAX response correctly or otherwise.
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Tick time ->
( { model | time = time }, Cmd.none )
Register result ->
( { model | system = Result.toMaybe result }
, Cmd.none
)
Consequently, the handlers for these two cases are very simple. Any Time
update simply updates the model, and any server Result
is recorded. As mentioned before, I have converted the Result
to Maybe
so I can encode the response has not yet been received in the same way as the request failed and the response is invalid. Don’t be fooled: this is pure laziness on my part.
View Ordering
type alias Coordinate =
( Float, Float )
A coordinate is represented as ( x, y )
. If I weren’t going for brevity, some operations would be helpful (e.g. add
).
type Renderable
= Orbit
{ x : Float
, y : Float
, radius : Float
}
| Planet
{ x : Float
, y : Float
, radius : Float
, colour : String
}
In the first iteration of this code, there was no ordering on the Svg
elements. This looked odd when satellites didn’t go behind a parent body (e.g. when the moon’s orbit is at the “back” of the diagram), so I picked a configuration where this didn’t happen. A few weeks later, however, I felt ashamed of myself, and fixed it.
So, now, we generate inspectable (thus easily orderable) records, and convert them to SVG
later on. I think this turns out quite neatly, though it’s perhaps the result of staring for too long at Free
structures and interpreters.
ordering : Renderable -> Float
ordering object =
case object of
Planet { y } -> y
_ -> negate 1 / 0
For now, we just put Orbit
rings right at the back. This looks a bit odd at times, and I think a better solution would be to split up the ellipse
into several arcs, (to order around other Svg
s), but that’s one for another time.
Converting a Renderable
to an Svg
is very straightforward, as all the required information is stored within the Renderable
type already. An easy modification to this app would be to allow user-defined camera tilt (i.e. configurable skew
), and it could simply be passed in as a parameter here.
It’s perhaps worth pointing out that my <<
and <|
usage is a direct mapping from Haskell’s .
and $
. The <<
could quite easily be replaced with another <|
of course, but it’s just not the way I’m used to doing things! Old dogs, new tricks…
renderables : Coordinate -> Time -> System -> List Renderable
renderables ( cx, cy ) time { orbit, colour, radius, speed, moons } =
let
( cx_, cy_ ) =
fromPolar (orbit, speed * inMinutes time)
|> \( x, y ) -> ( cx + x, cy + 0.4 * y )
ring =
Orbit { x = cx
, y = cy
, radius = orbit
}
planet =
Planet { x = cx_
, y = cy_
, radius = radius
, colour = colour
}
subrenderer =
renderables ( cx_, cy_ ) time
children =
case moons of
Moons ms ->
List.concatMap subrenderer ms
in
ring :: planet :: children
View Construction
toSvg : Renderable -> Svg.Svg Msg
toSvg renderable =
case renderable of
Orbit { x, y, radius } ->
Svg.ellipse [ cx <| toString x
, cy <| toString y
, rx <| toString radius
, ry << toString <| 0.4 * radius
] []
Planet { x, y, radius, colour } ->
Svg.circle [ cx <| toString x
, cy <| toString y
, r <| toString radius
, style <| "fill:" ++ colour
] []
See where those Coordinate
functions would be useful?
I’ll say now that I don’t like this function: there’s a pretty obvious optimisation to be made here. What I would really like is a type of Coordinate -> System -> List (Time -> Renderable)
, or even a result like Time -> List Renderable
: in other words, we’d end up with a list of bodies positioned with respect to each recursive orbit via composed functions.
I haven’t looked enough into Elm’s compiler to know for certain, but I would imagine that this could be compiled efficiently if we ended up with let .. in \time ->
as our general form. As time
is the only important variable here, we could pre-build all this at the time of JSON receipt, and cut down on the calculations needed at run-time. It’s just a thought, really.
For small-ish System
cases, this works fine, but I’m not really a huge fan of the good enough mentality, and I’m certain this function has room for improvement. It certainly shouldn’t need to know the skew
value to build view-independent coordinates.
view : Model -> Html.Html Msg
view { time, system } =
let
container =
Svg.svg [ viewBox "0 0 600 240"
, width "600px"
]
in
case system of
Nothing ->
Html.div [] [ Html.text "What a quiet night..." ]
Just data ->
container << List.map toSvg
<< List.sortBy ordering
<< renderables ( 300.0, 120.0 ) time
<| data
Finally, we have the view function: the entry point for all the rendering process. This is nice and easy to understand, I hope: we can display some cursory error to the user for when AJAX fails, and otherwise kick off the animation.
Of course, I’d love to get skew
into the Model
so that it can be configured (and then passed to the rest of the view logic at render-time). Ooer.
That gets us to the end of the file! All the code for the top visualisation (that isn’t just imported library code) is here: it really is that simple. If you want to see it more clearly, there is a Gist of all the code to be found here.
Elm is wonderful. Try it.
Take care ♥