1. Qiita
  2. Items
  3. cycle.js

How to make a Cycle.js + Elm hybrid Etch-a-Sketch app

  • 9
    Like
  • 0
    Comment
More than 1 year has passed since last update.

A couple of days ago, I wrote a Cycle.js app using Elm to render stuff to the page. This was pretty fun and got some attention, so I might as well write about it some.

Edit: Here's a gist version since the Elm highlighting on Qiita doesn't work that great: https://gist.github.com/justinwoo/03fc40121f2ff5bef5d9

Writing this thing

Overview

A Cycle.js application consists of three parts: your main function declaration, your drivers setup, and your application initialization with Cycle.run (which sets up the "Cycle" part of your application). We'll do the following in our app:

  • We'll delegate the side effects of drawing to the DOM to Elm, which can draw to a canvas for us.
  • We'll also get screen click events from Elm, so that we can clear the screen when we double click.
  • We'll use window events to get keystrokes.

You'll notice that all of these are side-effectey things. Side effects are done through "drivers" in Cycle.js. I'll show how my drivers are made eventually, so let's get started.

Setup

Like usual, I used Webpack with my elm-simple-loader to easily write Elm and have it build into my app. Here are the relevant bits of my config:

//package.json
  "devDependencies": {
    "@cycle/core": "^4.0.0",
    "babel-core": "^5.8.25",
    "babel-loader": "^5.3.2",
    "elm-simple-loader": "^0.1.1",
    "webpack": "^1.12.2"
  }
// webpack.config.js
  module: {
    loaders: [{
      test: /\.js$/,
      loaders: ['babel'],
      exclude: /node_modules/,
      include: __dirname
    }, {
      test: /\.elm$/,
      loaders: ['elm-simple-loader'],
      exclude: /node_modules/
    }]
  }

Our Elm app

I wrote my Elm app first, since the mantra is "if it compiles, it works". The base of our application are the type definitions:

-- 2-dimensional coordinates here
type alias Coordinates = (Int, Int)

type alias Model =
  {
    points: List Coordinates,
    cursor: Coordinates
  }

Coordinates are integer tuples, and Model is a record/object with points, which are the points that our Etch-a-Sketch has drawn, and cursor, which is where our drawing cursor is currently. Make enough sense so far?

My drawing code is here, but it's nothing really special:

drawPoint : Color -> Coordinates -> Form
drawPoint color coords =
  let
    (x, y) = coords
  in
    rect cellSize cellSize
      |> filled color
      |> move (toFloat x * cellSize, toFloat y * cellSize)

view : (Int, Int) -> Model -> Element
view (w, h) model =
  let
    points =
      List.map (drawPoint (rgb 50 50 50)) model.points
    cursor =
      drawPoint (rgb 0 0 0) model.cursor
  in
    List.append points [cursor]
    |> collage w h

You can see that the view function takes in the size of our canvas and our model to produce our element tree (the "virtual dom").

The most important parts of my application have to do with how signals are managed:

port mouseClicks : Signal ()
port mouseClicks = Mouse.clicks

port model : Signal Model

main : Signal Element
main =
  Signal.map2 view Window.dimensions model

So at the top we use the Elm standard Mouse module to get clicks to the screen, and we expose that through a port as an outgoing signal. We'll use this from our JS side.

Then we have a port for model, which has no definition, so that's an inbound signal port for getting our receiving our model.

Then we have the main signal for our Elm app, which is a signal that produces element trees so that the Elm runtime can mount and render it. We map the dimensions of our window and our model signals to our view function to get our element tree. Pretty normal Elm code.

Our Board driver

So the Elm app we have is then managed by a "Board driver", which is what is responsible for getting a stream of data in from our application to get it to render. Let's go over this bit by bit:

import Elm from './Board.elm';

export default function makeBoardDriver() {
  return function boardDriver(model$) {

Thanks to my handy elm-simple-loader loader, I'm able to use the Elm variable exposed by the Elm runtime. The makeBoardDriver function is a little bit unnecessary because we don't have any configuration options to pass in through its arguments, but it's nice to have some uniformity. This then returns the boardDriver function, which in my case takes the model stream to perform operations. Let's move on.

  return function boardDriver(model$) {
    let board;
    let screenClick$ = new Rx.Subject();
    let requestScreenClear$ = screenClick$
      .buffer(function() {
        return screenClick$.debounce(250);
      })
      .map(function (events) {
        return events.length;
      })
      .filter(function (clicks) {
        return clicks >= 2;
      });

    model$.first().subscribe(function (model) {
      board = Elm.fullscreen(Elm.Board, {
        model
      });

      board.ports.mouseClicks.subscribe(function () {
        screenClick$.onNext();
      });
    });

    model$.subscribe(function (model) {
      board.ports.model.send(model);
    });

    return {
      requestScreenClear$
    };
  };

So I have a declaration for my board, which will hold the instance for my Elm app. My screenClick$ subject will be pushed to whenever I receive clicks from my Elm app, and then that is transformed so that I use a buffer to detect when double clicks happen in my application.

You'll see that I use the first value of my model stream to initialize my board and then set up the mouse clicks, and then subscribe to my model stream to send the board my model state again. This is done because Elm signals populate the initial value of a signal, but do not fire an event for processing, so I have to send it my signal value again to get the my Elm app to render. Just a small detail here.

I then return my requested screen clears stream, so that I can handle that to clear the points of my model.

Our Keyboard driver

import Rx from 'rx';

export const UP = 'up';
export const DOWN = 'down';
export const LEFT = 'left';
export const RIGHT = 'right';

const UP_INPUTS = [38, 75];
const DOWN_INPUTS = [40, 74];
const LEFT_INPUTS = [37, 72];
const RIGHT_INPUTS = [39, 76];

const MAPPINGS = [
  [UP_INPUTS, UP],
  [DOWN_INPUTS, DOWN],
  [LEFT_INPUTS, LEFT],
  [RIGHT_INPUTS, RIGHT],
];

export default function makeKeyboardDriver() {
  return function keyboardDriver() {
    const directionInput$ = Rx.Observable.fromEvent(window, 'keydown')
      .map(function ({keyCode}) {
        for (let i = 0; i < MAPPINGS.length; i++) {
          const [inputs, direction] = MAPPINGS[i];

          if (inputs.indexOf(keyCode) !== -1) {
            return direction;
          }
        }
      });

    return {
      directionInput$
    };
  }
}

There's not much to be said about my keyboard driver. It just takes the window keyboard events to figure out which direction I should send back.

Our Cycle.js app

Our drivers are then initialized in index.js and then fed into Cycle.run:

let drivers = {
  keyboard: makeKeyboardDriver(),
  board: makeBoardDriver()
};

Cycle.run(main, drivers);

Our main function has some details we need to go through. The gist of my main function looks like this:

function main(drivers) {
  const INITIAL_STATE = {
    points: [],
    cursor: [0, 0]
  };

  const moveCursor$ = [...]

  const clearScreen$ = [...]

  const state$ = Rx.Observable
    .merge(
      moveCursor$,
      clearScreen$
    )
    .startWith(INITIAL_STATE)
    .scan(function (state, mapper) {
      return mapper(state);
    });

  return {
    board: state$
  };
}

We have a INITIAL_STATE that our application starts with, and then we have two streams of mapping functions: one for moving the cursor, and one for clearing the screen. These two mapping function streams are merged for our state stream, which starts with the initial state as its value, and then scans/reduces over our mapping functions to produce our state. In practice, you might want to just transform these streams to identify them by action ID and payload, but I'll leave that decision up to you.

Most importantly, you'll see that what we return from our main is a sink where they key is the name of the driver and the value the argument to be fed into that driver when it is called. So when Cycle.run does it magic, it makes sure that our board driver can get the state stream produced by main, and the main can get our board driver's output as drivers.board.

The rest of this is rather boring stuff:

function deduplicatePoints(points) {
  let newPoints = [];
  const pointsObject = points.reduce(function (aggregate, point) {
    aggregate[point.join(',')] = point;
    return aggregate;
  }, {});

  for (let key in pointsObject) {
    newPoints.push(pointsObject[key]);
  }

  return newPoints;
}

function addPoint(model) {
  let points = model.points.slice();

  points.push(model.cursor);
  return points;
}

function main(drivers) {
  const INITIAL_STATE = {
    points: [],
    cursor: [0, 0]
  };

  const moveCursor$ = drivers.keyboard.directionInput$
    .map(function (direction) {
      return function (model) {
        if (!direction) return model;

        const points = deduplicatePoints(addPoint(model));
        let [cursorX, cursorY] = model.cursor;

        switch (direction) {
          case UP:
            cursorY++;
            break;
          case DOWN:
            cursorY--;
            break;
          case LEFT:
            cursorX--;
            break;
          case RIGHT:
            cursorX++;
            break;
        }

        return {
          points,
          cursor: [cursorX, cursorY]
        };
      };
    });

  const clearScreen$ = drivers.board.requestScreenClear$
    .map(function () {
      return function (model) {
        return {
          points: [],
          cursor: model.cursor
        };
      };
    });

Cycle diagram

Here's my beautiful cycle diagram after 20 minutes in Graphic.app:

cyclesss.png

Conclusion

Hopefully I showed that anyone can write Cycle.js drivers that meets their needs, and how integrating Elm apps into normal JS apps is quite easy. You'll also see that I didn't use the DOM driver at all, and so can you! Since Cycle.js is barely even a framework, you can do whatever you want! You can even ride a horse while driving a car, if you want! #shittyslightlyobscurejoke

If you made it this far, thanks! Let me know if was useful, amusing, awful, and/or the worst thing you've ever read on Twitter.

If you've just getting started with Cycle.js, you might enjoy Frederik's article here.

Demo & Repo

Demo: http://justinwoo.github.io/cycle-elm-etch-sketch

Repo: https://github.com/justinwoo/cycle-elm-etch-sketch