Knockout binding for JavaScript route fixup

Part one.

After the first round, I felt compelled to KnockOut the code a bit more. I’d mentioned I wasn’t pleased with the code exactly. It needed some refactoring.

So, I’ve created a new Knockout binding handler. This binding handler replaces  named parameters with a model’s properties in a path.

For example, given this object:

Property Name Value
id A123
first_name Aaron
state WI

The following paths would be converted thusly:

Original Replaced
/person/{id} /person/A123
/person/{state}/{id} /person/WI/A123

You get the idea. Here’s the JavaScript code:

ko.bindingHandlers['route'] = {
    // Examples: 
    //      <a data-bind="route: {model: $data, url: 'person_details', attr: 'href' }" >
    // or, you can shortcut the syntax to default to the currently bound object and just pass 
    // the url or the route name as a string directly
    //      <a data-bind="route: 'person_details' }" >
    update: function (element, valueAccessor, allBindingsAccessor, $data) {
        var valueUnwrapped = ko.utils.unwrapObservable(valueAccessor());
        var options = ko.bindingHandlers.route.options || {};

        // look for the model property 
        var model = ko.utils.unwrapObservable(valueUnwrapped['model']);
        if (typeof model !== 'object') {

            model = ko.utils.unwrapObservable($data);
            if (typeof model === 'undefined' || model === null) {
                throw new Error('set route model to object (or nothing bound?)');
            }
        }

        // look for the url property first
        var url = ko.utils.unwrapObservable(valueUnwrapped['url']);
        // validate we've got something as a url (might be a name, might be a full url)        
        if (typeof url !== 'string' || url == "") {
            url = valueUnwrapped;
            if (typeof url !== 'string' || url == "") {
                throw new Error("set route url property to route name or url directly");
            }
        }

        // is it on the keyed collection?
        var map = options.map;
        if (typeof map !== 'undefined' && map !== null) {
            if (map.hasOwnProperty(url)) {
                url = map[url];
            }
        }
        // check for a routing function as well
        var fn = options.routeNameToUrl;
        if (typeof fn === 'function') { url = fn.call(null, url); }
        // did we get something meaningful?
        if (url !== null && url !== '' && url.length > 0) {
            url = ko.bindingHandlers.route.buildUrl(url, model);            
        }
        // the url might need some fixin after a routing, anything goes here (might just be a default)
        fn = options.fixUrl;
        if (typeof fn === 'function') { url = fn.call(null, url); }
        element.setAttribute(ko.utils.unwrapObservable(valueUnwrapped['attr']) || 'href', url);
    },    

    // given a model, this function replaces named parameters in a simple string 
    // with values from the model
    //     /path/to/some/{id}/{category}
    // with object { 'id' : 'abc', 'category' : 'cars' }
    // becomes
    //     /path/to/some/abc/cars
    buildUrl : function(url, model) {
        // unfixed if there's not a thing
        if (typeof model === 'undefined' || model === null) { return url; }

        var propValue;
        for (var propName in model) {                
            if (model.hasOwnProperty(propName)) {
                propValue = model[propName];
                if (ko) { propValue = ko.utils.unwrapObservable(propValue); }

                if (typeof propValue === 'undefined' || propValue === null) {
                    propValue = "";
                } else {
                    propValue = propValue.toString();
                }
                url = url.replace('{' + propName.toLowerCase() + '}', propValue);
            }
        }
        return url;
    },

    options: {
        // ** convert a route name to a url through whatever means you'd like
        // routeNameToUrl : function(routeName) { return url; } 

        // ** anything you want, called after routeNameToUrl, might add a virtual directory
        // ** for example
        // fixUrl: function(url) { return url;  }  

        // ** A map route names to URLs **
        // all other functions are called if set (to possibly override this)
        // this is not required if you use one of the other functions
        // map : { 'a_route_name' : '/path/to/something/{id}/{action}' }        
    }
};

In another JavaScript file, I did initialize some of the options:

ko.bindingHandlers.route.options.routeNameToUrl = getRoute;
ko.bindingHandlers.route.options.fixUrl = app_url;

The getRoute function just maps a route name to a path, and the app_url prepends the virtual directory to the path as needed.

Here it is in use:

<div data-bind="foreach: data.persons">
    <h3 class="title" data-bind="text: Title"></h3>
    <div>
        <a data-bind="route: { model: $data, url: 'person_details', attr: 'href' } ">Details2</a>
        <a data-bind="route: '/data/details/{id}/{title}' ">Details</a>
        <a data-bind="route: 'person_details' ">Details</a>
    </div>
</div>

You’ll probably like the new way better syntactically at least compared to the old way. A route binding requires one input when used in it’s most basic form:

  • url = the URL or route name to use as the template for the replacement. It should contain (or later resolve to) curly-braced enclosed property name keys which will be substituted by values from the model. The value of the property could be either a route name (see options below) or a path.

When using just the basic form you can use the shortened syntax:

<a data-bind="route: 'research_details' ">Details</a>

This handily binds to the current object (via the bindingContext.$data property of the bindingHandler update function call, which is also the fourth parameter, which I’ve renamed to $data rather than the typical viewModel). So, you won’t need to necessarily (explicitly) set the model for the binding.

If you need a a bit more control, you can change the syntax and get access to a few other options (including direct access to the model you want to bind to if the current item isn’t directly what you want to use).

  • model = this is the object that contains the properties and values to be used as replacements within the url
  • attr (optional) = the name of the attribute to set the generated url into. Defaults to href if not set.

There are a few global options you can control as well:

  • routeNameToUrl = (function)(routeName) optionally, given a route name, should return the path (or the original value). Here you can do a lookup of routeName to path.
  • map = {object} the object properties should be route names and set equal to the path. this optional lookup is performed before the routeNameToUrl function is called.
  • fixUrl = (function)(url) do anything here. this is called after the mapping and routeNameToUrl is optionally called. I use this to correct javascript Ajax request paths by appending the application virtual directory

(Thanks to Ryan for the suggestion to use the $data on the bindingContext, and then again for the nudge to just use the 4th parameter. Smile )

Added Away/Home to unofficial-nest-api

I just finished adding a new simple feature to control the away status for a structure to my unofficial-nest-api published on GitHub and available as a node package (npm).

Usage is simple as calling setAway or setHome on the nest instance after authentication and a successful status has been returned (see commented calls below).

[javascript]
if (username && password) {
username = trimQuotes(username);
password = trimQuotes(password);
nest.login(username, password, function (data) {
if (!data) {
console.log(‘Login failed.’);
process.exit(1);
return;
}
console.log(‘Logged in.’);
nest.fetchStatus(function (data) {
for (var deviceId in data.device) {
if (data.device.hasOwnProperty(deviceId)) {
var device = data.shared[deviceId];
console.log(util.format("%s [%s], Current temperature = %d F target=%d",
device.name, deviceId,
nest.ctof(device.current_temperature),
nest.ctof(device.target_temperature)));
}
}
subscribe();
//nest.setAway();
//nest.setHome();
});
});
}
[/javascript]

A bit of reflow, using TweenJS

Inspired by some modern applications (especially the “Metro” look and feel), I wanted to put together an animated tile reflow script for an HTML page I was creating.

As the size of the page changes, the tiles move into their new positions.

SNAGHTML113b633f[4]

You can try it here, if you’re using a modern browser (IE9+, Chrome, FF, Safari, etc.).

Dependencies:

  1. TweenJS
  2. EaselJS (for the ticker used in TweenJS)
  3. CSSPlugin (an add on to the the TweenJS library)
  4. jQuery (1.7.2)

Relayouts are queued (as to prevent multiple from running).

Only boxes that will be visible to the end user (either animating to the screen or off the screen) are animated. It does not animate elements that do not start or end in the current viewport including boxes that would animate “through” the current viewport. I considered the latter to not add anything to the overall experience and just uses more CPU to perform the animations, so I intentionally left it out.

In the layout loop, the code verifies that the animation needs to run:

// here's the final destination
pos = { left: Math.round(x), top: Math.round(y) };

// check old and new positions, if either are in the viewport, we'll animate
if (isScrolledIntoView(pos.top + parentOfBoxesOffsetTop, boxH) || isScrolledIntoView(box.y + parentOfBoxesOffsetTop, boxH)) {
    Tween.get(box.e).to(pos, 500 + 500 * Math.random(), 
        Ease.quadInOut).call(function () {
            --layoutPending;
            if (!layoutPending && queuedLayout) { queueLayout(); }
    });
} else {

If it needs to animate, the code uses the static Tween method “get” to start an animation on the current element.

  1. Tween.get(box.e) creates a new instance of the Tween class (initialized with the element to tween).
  2. Using that Tween instance, the CSS attributes (thanks to the handy CSSPlugin), for left and top are animated for at least 500ms and up to a full second. The easing function I chose is quadInOut. Tip: Here’s a great way to visualize easing options using TweenJS.
  3. Finally, when the animation completes, an anonymous function is called which decrements the number of pending animations and if there are no remaining elements to animate, and there’s something queued, another cycle is started.

All the code used by the demo is in default.html.

Colors of the boxes were set using hsl (only available in modern browsers):

box.style.backgroundColor = "hsl(" + c + ", 100%, 50%)";

Where “c” is:

c = (i / boxes * 360).toFixed(1);

SVG Setting Text Content Dynamically

Continuing on a theme of animating SVG, I’ve added a label in the center of the animation which contains the current angle of the ever-rotating marker (in fact in a centered horizontally and vertically SVG text node).

(Click image below to try it.)

image

At the bottom of the file from the previous post, I’ve added the following:

    <text id="currentValueAsText" x="300" y="300" 
        style="text-anchor: middle;alignment-baseline:middle" 
        fill="#FFFFFF" font-family="'Arial'" font-size="96" opacity=".6">0</text>

It’s a new text node. It’s set to centered both horizontally and vertically (using the text-anchor and alignment-baseline style properties respectively) at 300,300.

Setting the content is simple as well:

(function () {
    window.onload = loaded;
    function loaded() {
        var colorTemp = document.getElementById("color-temp");
        var reading = document.getElementById('current-reading');
        var currentVal = document.getElementById("currentValueAsText");
        var currentAngle = 0;
        var fill;

        var direction = 1;
        setInterval(function () {
            currentAngle += direction;
            if (currentAngle >= 120 || currentAngle <= -120) {
                direction *= -1;
            } else if (currentAngle === 0) {
                fill = direction === 1 ? "#BE1E2D" : "#10A2DC";
                colorTemp.setAttribute("fill", fill);
            }
            // adjust the opacity
            colorTemp.setAttribute("opacity", Math.abs(currentAngle) / 120.0);
            reading.setAttribute("transform", "rotate(" + currentAngle + ")");
            currentValueAsText.textContent = currentAngle.toString();

        }, 25);
    }
})();

Use the textContent property of the SVG text node to set the text.

Done. Smile