Sunday, November 19, 2017

An AngularJS Dashboard, Part 7: Smart Tile Fill Algorithm

NOTE: for best results, view the http: version of this page (else you won't get syntax highlighting).

This is Part 7 in a series on creating a dashboard in AngularJS. I'm blogging as I progressively create the dashboard, refining my Angular experience along the way.

Previously in Part 6, we added confirmation dialogs and added a Make Default Layout action. Today, we're going to address something that's been bothering me all along (and probably you readers as well): the lack of a smart tile fill algorithm.

Today we will:

• Implement a smart-fill algorithm so that our dashboard is always filled well, no matter what  mix of tile sizes and screen size we have.
• Update the markup, CSS, and JavaScript so that we get good dashboard rendering across all popular browsers, including Chrome, Firefox, Safari, Edge, and IE.

Here's what we'll end up with today. For a limited time, there is an online demo available as well.


Smart Tile Fill

From the very start our TFS-inspired dashboard has supported tiles that are 1- and 2-units wide or tall, for a total of 4 sizes in all. That's useful, but it also creates a problem: your dashboard might be beautiful or it might be full of empty areas. We've admitted all along that depending on how many tile sizes you use and the screen width your dashboard renders on you could easily end up with a bunch of awkward gaps.

Our Past Attempts

Our initial tile rendering in Part 1 simply used a 200px multiplier on tile width and height, plus a right- and bottom-margin of 16px, letting the tiles flow on the page and letting the browser wrap to the next line when there was no more room for tiles on a row. Simple, but hardly elegant. Our dashboard was always geometrically aligned, but rarely filled well unless we restrained ourselves.

Knowing this initial approach wasn't good enough, in Part 2 we switched to using tables for layout (inwardly anticipating hate mail from designers who have long maintained tables shouldn't be used for layout). We also added a function to our controller, computeLayout, that is called after a dashboard is loaded; and, whenever the dashboard is changed. computeLayout's job was to come up a row-column tile layout based on the user's current browser window width, and to decide what would fit on each row of the table. The thinking was this would be an improvement on our initial approach. We leveraged colspan="2" for wide tiles, and we expected to eventually add smarts to also use rowspan="2" for tall tiles. In turn, our HTML template has two nested ng-repeat loops: an outer loop to iterate through the row list that computeLayout produced, and an inner one to iterate through the columns of each row. While all of this did improve tile rendering, it hardly resolved all the issues. We were—um—careful to only show our examples with tile layouts and browser widths that just happened to render well. Again, it was still quite possible have dashboards that had a lot of holes in them.

In Parts 3-6, we concentrate on other important matters--namely, implementing tiles, two chart services, two chart services, and tile actions. Our dashboard really started coming to life--but all along, our not-quite-there-yet tile layout cast a shadow on all our work.

Revisiting Tile Layout

Now that we've reviewed our past attempts, how are we going to do better? Here's the plan:
  1. Abandon the use of a table for controlling layout. Instead, our controller's computeLayout function will compute exact x and y positioning for each tile. The HTML template will position each tile precisely using position: absolute (within a position: relative containing div).
  2. In computeLayout, use a bitmap approach to track the filled / available areas of a matrix.
  3. In compueLayout, for each tile we need to place, find the best open spot in the bitmap for each tile and set its x and y from that.
That's the plan. Now let's get to it.

Using a Bitmap in computeLayout

A bitmap is a simple matrix (table) of true/false flags. In the controller's computeLayout function, we declare an array of arrays named matrix. Although this will hold integers, we're only writing and reading ones and zeroes, so logically we're treating matrix as a bitmap. We're going to set a limit that a dashboard will never have more than 10 rows of 20 tile units across.
self.tilesdown = 10;        // hard-coded limitation for now.

...

// Compute layout based on current screen dimensions (self.tablecolumns()) and generate updated tile layout
// Inputs: self.tablecolumns() ........... number of tiles across.
// Ouptuts: self.tiles ................... sets .x and .y property of each tile to indicate where it should be positioned.

self.computeLayout = function () {
    if (self.tiles == null) return;

    var matrix = [[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0]];

    var numcols = self.tablecolumns();
    if (numcols < 1) numcols = 1;

    // This is used in template to render things like table <col> elements.
    self.tilesacross = numcols;
    self.tileunits = [];
    for (var u = 0; u < numcols; u++) {
        self.tileunits.push(u);
    }

Initializing matrix (bitmap)

Later in the computeLayout function, the code iterates through each tile. There are four versions of similar code, one for each possible tile size. The code loops through the matrix, looking for a "hole" of zeroes in the right shape. Once it finds a hole, it sets the hole to ones in the matrix and sets the tiles's x and y properties--mulitplying by 200 and adding in 16 for spacing between tile units.

var xMargin = 16;
var yMargin = 16;
for (var t = 0; t < self.tiles.length; t++) {
    tile = self.tiles[t];
    if (index + parseInt(tile.width) > numcols) { // no more room in row. commit row and start new one.
        newlayout.push(row);
        row = [];
        index = 0;
    }
    tile.id = tileid.toString();
    tileid = tileid + 1;
    var across = self.tilesacross - 1;
    if (across > 20) across = 20;       // enforce limit of 20 tile units across
    if (across < 1) across = 1;
     switch (tile.height) {
        case '1':
            switch (tile.width) {
                case '1':
                    // find a 1x1 available location
                    loop_1_1:
                    for (var r = 0; r < self.tilesdown; r++) {
                        for (var c = 0; c < self.tilesacross; c++) {
                            if (matrix[r][c] === 0) {
                                tile.x = xMargin + (c * 216);
                                tile.y = yMargin + (r * 216);
                                matrix[r][c] = 1;
                                //console.log('tile ' + tile.id + ' (1x1): r:' + r.toString() + ', c:' + c.toString() + ', x:' + tile.x.toString() + ', y:' + tile.y.toString());
                                break loop_1_1;
                            } // end if
                        } // next c
                    } // next r
                    break;
                case '2':
                    // find a 2x1 available location
                    loop_2_1:

                        for (var r = 0; r < self.tilesdown; r++) {
                        for (var c = 0; c < across; c++) {
                            if (matrix[r][c] === 0 && matrix[r][c+1]===0) {
                                tile.x = xMargin + (c * 216);
                                tile.y = yMargin + (r * 216);
                                matrix[r][c] = 1;
                                matrix[r][c + 1] = 1;
                                //console.log('tile ' + tile.id + ' (2x1): r:' + r.toString() + ', c:' + c.toString() + ', x:' + tile.x.toString() + ', y:' + tile.y.toString());
                                break loop_2_1;
                            } // end if
                        } // next c
                    } // next r
                    break
            } // end switch
            break;
        case '2':
            switch (tile.width) {
                case '1':
                    // find a 1x2 available location
                    loop_1_2:
                        for (var r = 0; r < self.tilesdown - 1; r++) {
                        for (var c = 0; c < self.tilesacross; c++) {
                            if (matrix[r][c]===0 && matrix[r+1][c]===0) {
                                tile.x = xMargin + (c * 216);
                                tile.y = yMargin + (r * 216);
                                matrix[r][c] = 1;
                                matrix[r + 1][c] = 1;
                                //console.log('tile ' + tile.id + ' (1x2): r:' + r.toString() + ', c:' + c.toString() + ', x:' + tile.x.toString() + ', y:' + tile.y.toString());
                                break loop_1_2;
                            } // end if
                        } // next c
                    } // next r
                    break;
                case '2':
                    // find a 2x2 available location
                    loop_2_2:
                        for (var r = 0; r < self.tilesdown - 1; r++) {
                        for (var c = 0; c < across; c++) {
                            if (matrix[r][c] === 0 && matrix[r][c + 1] === 0 &&
                                matrix[r+1][c] === 0 && matrix[r+1][c+1] === 0) {
                                tile.x = xMargin + (c * 216);
                                tile.y = yMargin + (r * 216);
                                matrix[r][c] = 1;
                                matrix[r][c + 1] = 1;
                                matrix[r+1][c] = 1;
                                matrix[r + 1][c + 1] = 1;
                                //console.log('tile ' + tile.id + ' (2x2): r:' + r.toString() + ', c:' + c.toString() + ', x:' + tile.x.toString() + ', y:' + tile.y.toString());
                                break loop_2_2;
                            } // end if
                        } // next c
                    } // next r
                    break
            } // end switch
            break;
    }

Finding a Hole for each Tile Size

Template Rendering

Once the x and y positions have been computed for each tile, the template is rendered. The previous use of a table has been eliminated. We now have a single ng-repeat loop (line 5) that cycles through the controller's tiles array. The outer dashboard div (line 4) is now display: relative, and within that contain tiles are positioned absolutely (line 9) with display: absolute. The tile's x and y properties (set by computeLayout) are used to position the tile in line 10.
<div class="dashboard-controller" window-size>
    <div>
        <div>{{$ctrl.title}} (chart provider: {{$ctrl.chartProvider}} - data provider: {{$ctrl.dataProvider}})</div>
        <div class="dashboard-panel" style="position: relative" ng-style="{'cursor': $ctrl.waiting ? 'wait' : 'default'}">
            <null ng-repeat="tile in $ctrl.tiles track by $index">
                ...
                <!-- Populated tile (data loaded) -->
                <div id="tile-{{tile.id}}" ng-if="tile.haveData"
                        class="tile" ng-class="tile.classes" ng-style="{ 'background-color': $ctrl.tileColor(tile.id), 'color': $ctrl.tileTextColor(tile.id), 'top': $ctrl.tileY(tile.id), 'left': $ctrl.tileX(tile.id) }"
                        style="overflow: hidden; position: absolute; display: inline-block"
                        draggable="true" ondragstart="tile_dragstart(event);"
                        ondrop="tile_drop(event);" ondragover="tile_dragover(event);">
                    <div class="dropdown" style="height: 100%">
                        <div class="hovermenu">
                            <i class="fa fa-ellipsis-h dropdown-toggle" data-toggle="dropdown" aria-hidden="true"></i>
                            <ul class="dropdown-menu" style="margin-top: -30px; margin-left:-150px !important">
                                <li><a id="tile-config-{{tile.id}}" href="#" onclick="configureTile(this.id);"><i class="fa fa-gear" aria-hidden="true"></i>  Configure Tile</a></li>
                                <li><a href="#" onclick="configureTile('0');"><i class="fa fa-plus-square-o" aria-hidden="true"></i>  Add Tile</a></li>
                                <li><a id=tile-remove-{{tile.id}}" href="#" onclick="removeTileConfirm(this.id);"><i class="fa fa-trash-o" aria-hidden="true"></i>  Remove Tile</a></li>
                                <li><a id=tile-reset-{{tile.id}}" href="#" onclick="resetDashboardConfirm();"><i class="fa fa-refresh" aria-hidden="true"></i>  Reset Dashboard</a></li>
                                <li ng-if="$ctrl.user.IsAdmin"><a id=tile-reset-{{tile.id}}" href="#" onclick="saveDefaultDashboardConfirm();"><i class="fa fa-check-square-o" aria-hidden="true"></i>  Make Default Layout</a></li>
                            </ul>
                        </div>
                        ...markup for each kind of tile...
                    </div> <!-- end tile -->
                </null>
        </div>
    </div>
   ...
</div>
And that's pretty much all there is to it.

Dashboard Smart Fill in Action

Now to see how well this all works. To start with, at 1920 x 1080, we're going to set up the following dashboard tiles. Note we're going out of our way to have an interesting mix of tiles sizes so that we can put this code through its paces.
  1. Customers: red 1x1 counter tile
  2. Customer Satisfaction: dark red 2x1 KPI tile
  3. Revenue Share Per Store: greed 2x1 pie chart tile
  4. Orders: gold 2x2 table tile
  5. Revenue by Store: blue 2x2 bar chart tile
  6. Orders: orange 1x1 counter tile
Here's how this dashboard looks at 1920 x 1080.

Mix-tile sizes at 1920 x 1080

We've added some console.log statements in computeLayout so you can see how the tile placement decisions are made. Note that Tile 5 (orange orders 1x1 tile) is not at the end--there was room in the placement for a 1x1 tile on the first row, so it was moved to position row 0 column 5 to avoid a gap.

Here's the same dashboard at about 2/3 the screen width, 1300 pixels across:

Same dashboard, 1300 pixels across


We can see that the rendered layout has shifted. By the time we get to tile 3, we're already out of space on the first row, so now we start filling tiles on row 2. But there's room for tile 5 up at row 0 column 3, so it gets moved. 

Lastly, let's go to an iPhone6-size window. This time, we'll graphically indicate how the bitmap is used as well.


Dashboard rendered on iPhone6 sized window, and bitmap


Once again, things have shifted to leverage the available space without waste. Notice that tile 5 was moved up to row 1, column 2 because there was a gap there of the right size. 

Let's consider another example. Below is a different dashboard at 1920 across. This time, we've altered the titles to include the tile number so it will be easy to see when things get re-arranged.

Dashboard 2, 1920 across

In the above rendering, tiles 1-8 are rendered in their defined sequence, because they fit well. Now, we'll reduce our width to about 700 pixels across. Notice in the rendering below that tile 7 follows tile 5--it's been moved up to avoid the gap that would be there because there's no room or tile 6 on that row.


Dashboard 2, 700 across

Finally, we have the smart tile fill we vaguely envisioned at the start of this series.

Better Browser Support and Visual Improvements

We've done a lot of testing this time around in different browsers and we've fixed a number of things. Although we won't go into the details, the latest code has these improvements:

  • Added a JavaScript Promise polyfill for browsers that don't natively support Promise (such as IE).
  • Resolved an issue with the table tile where vertical and horizontal scroll bars didn't always appear, due to differences in how browsers interepret height: 100%. (affected Edge, IE, FireFox).
In addition to browser-specific issues, we also made some other general visual improvements:
  • Corrected some imperfect JavaScript controller code that wasn't always rendering charts well, depending on tile size and chart library in use.
  • Now re-renders tiles immediately when you change width or height in the configuration dialog.
  • Fixed a problem where the vertical spacing between tiles was different from the horizonal spacing between tiles, creating a noticable asymmetry.
  • The dashboard can now be rendered across a really large display (or across two monitors), up to 20 tile units (about 4320 pixels) across.

Summary

Today in Part 7 we implemented smart tile rendering as well as code updates to work well across all popular browsers--allowing us to now freely use our dashboard's tiles and sizes without concern about how it is going to look, no matter where it is viewed.

Download Code
Dashboard_07.zip
https://drive.google.com/file/d/1NQ9GU2a8bwjKV0lDL4P-km1qxWoCOBmv/view?usp=sharing

Next: An AngularJS Dashboard, Part 8: Role Support and Mobile Improvements




No comments: