Engineering a better menu with Reactive Streams

Note: The position of the mouse on this page is predictive. The rest of the page is about the implementation.

I read Guillermo Rauch’s excellent writeup recently about rich web applications; I was particularly fascinated by the mouse prediction section. This can be implemented in a number of ways, but using Observable streams makes it a piece of cake. The code is also very clean, modular and surprisingly intuitive. RxJS and Immutable.js were the only external dependencies I used. There is no jQuery anywhere, no framework, and the whole script weighs in at 29kb gzipped. Here is how I went about things:

1. Generate the input streams

Here, the only inputs I need are the mouse position and the time. We get the individual streams and merge them.

  var mouseStream = Rx.Observable.fromEvent(document, 'mousemove');
  var intervalStream = Rx.Observable
    .interval(100)
    .map(function (x) {return {type: 'timer'};});
  var combinedStream = Rx.Observable.merge(mousemove, intervalStream);

2. Map the input streams to state streams

This is the most important part of the code. Here, I try to map the combined stream to meaningful data. Here, we need to get the predicted position of the mouse. To do this, I have used a simple PID contoller on the mouse events. In addition to this, the mouse will also converge towards the position of the cursor as time passes on. In the map function, I can choose to return whatever parts of the state that I wish to return. In this example, I store the previous ten mouse positions, but decide to expose only the latest predicted position.

var state = {
  lastTenX: _.List(),
  lastTenY: _.List(),
  kp: 1.5,
  ki: -0.05,
  kd: 0.5,
  predictedX: 0.0,
  predictedY: 0.0
};
var cb = function (evt) {
  if(evt.type === 'timer') {
    var newX = (0.75 * state.predictedX + 0.25 * state.lastTenX.last());
    var newY = (0.75 * state.predictedY + 0.25 * state.lastTenY.last());
    state.predictedX = newX;
    state.predictedY = newY;
    return {
      x: newX,
      y: newY,
      type: 'timer'
    };
  }
  var getLastTen = function (queue, x) {
    queue = queue.concat(x);
    if (queue.size > 10) {
      queue = queue.rest();
    }
    return queue;
  };
  var dx = evt.pageX - state.lastTenX.last();
  var dy = evt.pageY - state.lastTenY.last();
  dx = dx * 9;
  dy = dy * 16;
  state.lastTenX = getLastTen(state.lastTenX, evt.pageX);
  state.lastTenY = getLastTen(state.lastTenY, evt.pageY);
  var sumSeq = function (list) {
    var sum = function (x, y) {
      return x + y;
    };
    return list.reduce(sum, 0);
  };
  var newX = state.kp * evt.pageX + state.ki * sumSeq(state.lastTenX) + state.kd * dx;
  var newY = state.kp * evt.pageY + state.ki * sumSeq(state.lastTenY) + state.kd * dy;
  state.predictedX = newX;
  state.predictedY = newY;
  return {
    x: newX,
    y: newY,
    type: 'moved'
  };
};
cb.bind(state);

Putting it all together

This is the easiest part of them all.

predictedPositions.subscribe(function (data) {
  document.getElementById('mover').style.webkitTransform = "translate(" + data.x + "px, " + data.y + "px)";
});

Further, If I have a menu element of arbitrary shape defined by f(x, y) = true for points on the element, we can see if the predicted mouse position is over this element by using

  var filterDonutPositions = function (a, b, c, data) {
    var x = (data.x - centerX);
    var y = (data.y - centerY);
    var dd = x * x + y * y;
    return (dd < outerRadius * outerRadius && dd > innerRadius * innerRadius);
  }.bind(centerX, centerY, innerRadius, outerRadius);
  var donutPositions = predictedPositions.filter(filterDonutPositions);

Using this, we can very easily support elements of varied shapes. For instance, f(x, y) for a donut would look like

function (x, y) {
  var X = x - center.x, Y = y - center.y;
  var dd = X * X + Y * Y;
  return (dd > innnerRadius * innerRadius && dd < outerRadius * outerRadius);
  //Try scrolling over the donut at the bottom
}

Yum