March 26, 2015

JavaScript Fixed Masonry responsive layout.

The idea of this responsive layout is to present all surfaces on a custom viewport preserving the aspect ratios and keeping all surfaces as big as possible.

The codePen of the working responsive layout example is here.

This layout function work with famo.us framework and  famous-flex lib.
Gist of the layout function is here.

/**
 * This Source Code is licensed under the MIT license. If a copy of the
 * MIT-license was not distributed with this file, You can obtain one at:
 * http://opensource.org/licenses/mit-license.html.
 *
 * @author: Oleksandr Zinchenko (Qvatra)
 * @license MIT
 */

/*global console*/
/*eslint no-console: 0*/

/**
 * Fits (sets max possible size) a collection of renderables with a given aspect ratios to the context size from left to right, and when the right edge is reached,
 * continues at the next row.
 *
 * |options|type|description|
 * |---|---|---|
 * |`[cellRatios]`|Array.Number|Array of the dataSource elements aspect ratios|
 *
 * Example:
 *
 * ```javascript
 * var FixedMasonryLayout = require('FixedMasonryLayout');
 *
 * var layoutController = new LayoutController({
 *   layout: FixedMasonryLayout,
 *   layoutOptions: {
 *     cellRatios: [1, 3, 1, 2]
 *   },
 *   dataSource: [
 *     new Surface({content: '1', properties:{background:'red'}}),
 *     new Surface({content: '2', properties:{background:'blue'}}),
 *     new Surface({content: '3', properties:{background:'green'}}),
 *     new Surface({content: '4', properties:{background:'yellow'}})
 *   ]
 * });
 * ```
 * @module
 */
define(function (require, exports, module) {
    // Define capabilities of this layout function
    var capabilities = {
        sequence: true,
        scrolling: false
    };

    // data
    var size;       // layout container size 
    var index;       // iterator
    var cellRatios;      // integer ratios of elements
    var gridArea;       // summ of all elements areas. used for calculation of the gridSize
    var grid;       // binary grid array. 0 means empty cell - 1 means occupied cell
    var gridRatio;      // aspect ratio of the grid
    var gridSize;      // size of the grid regarding to the gridArea parameter (not pixels)
    var nodes;       // array of all elements (nodes)
    var node;       // current element
    var set = {       // size and position of an element
        size: [0, 0],
        translate: [0, 0, 0.01]
    };

    // returns true if element of size=size could be fitted in to the grid at coordinates [x, y]
    function _canBeFitted(x, y, itemSize) {
        for (var j = y; j < y + itemSize[1]; j++) {
            for (var i = x; i < x + itemSize[0]; i++) {
                if (i > grid.length - 1 || j > grid[0].length - 1 || grid[i][j] == 1) {
                    return false;
                }
            }
        }
        return true;
    }

    // markes occupied grid area with '1'
    function _reservePlace(x, y, itemSize) {
        for (var j = y; j < y + itemSize[1]; j++) {
            for (var i = x; i < x + itemSize[0]; i++) {
                grid[i][j] = 1;
            }
        }
    }

    // returns position on the grid if element could be fitted or undefined otherwise
    function _tryToFit(itemSize) {
        for (var j = 0; j < grid[0].length; j++) {
            for (var i = 0; i < grid.length; i++) {
                if (_canBeFitted(i, j, itemSize)) {
                    _reservePlace(i, j, itemSize);
                    return [i, j];
                }
            }
        }
        return undefined;
    }

    // calculate recursively next possible position on the grid for the given element
    function _calculatePosition(size) {
        var gridPosition = _tryToFit(size);
        if (!gridPosition) { // make grid bigger (add additional row and col) 
            grid.forEach(function (col) { //adding extra row
                col.push(0);
            });
            var col = [];
            grid[0].forEach(function () { //adding extra col
                col.push(0);
            });
            grid.push(col);
            return _calculatePosition(size);
        }
        return gridPosition;
    }

    // calculate canvas size = min size of a rectangle that can fit all elements
    function _calculateCanvasSize() {
        var canvasSize = [0, 0];
        for (var i = 0; i < nodes.length; i++) {
            canvasSize[0] = Math.max(nodes[i].gridPosition[0] + nodes[i].aspectRatio, canvasSize[0]);
            canvasSize[1] = Math.max(nodes[i].gridPosition[1] + 1, canvasSize[1]);
        }
        return canvasSize;
    }

    // height align calculation
    function _alignHeight(scale, viewSize, canvasSize) {
        return (viewSize[1] - canvasSize[1] * scale) / (canvasSize[1] + 1);
    }

    // width align calculation 
    function _alignWidth(scale, viewSize, canvasSize) {
        var gutterInRow = [];
        var freeSpaceInRow = [];
        var numCellsInRow = [];
        var lastCellInRow = [];

        for (var i = 0; i < canvasSize[1]; i++) {
            numCellsInRow.push(0);
            freeSpaceInRow.push(0);
            gutterInRow.push(0);
            lastCellInRow.push(null);
        }

        for (var i = 0; i < nodes.length; i++) {
            var row = nodes[i].gridPosition[1];
            numCellsInRow[row] += 1;
            nodes[i]['positionInRow'] = numCellsInRow[row];
            lastCellInRow[row] = (lastCellInRow[row] === null || nodes[i].gridPosition[0] > lastCellInRow[row].gridPosition[0]) ? nodes[i] : lastCellInRow[row];
        }

        freeSpaceInRow = lastCellInRow.map(function (cell) {
            return viewSize[0] - (cell.gridPosition[0] + cell.aspectRatio) * scale;
        })

        for (var i = 0; i < freeSpaceInRow.length; i++) {
            gutterInRow[i] = Math.floor(freeSpaceInRow[i] / (numCellsInRow[i] + 1));
        }

        return gutterInRow;
    }

    // Layout function
    function FixedMasonryLayout(context, options) {
        // init
        size = context.size;
        cellRatios = options.cellRatios;
        reflowTransition = options.reflowTransition;

        // calculate total area of elements
        gridArea = 0;
        cellRatios.forEach(function (cellRatio) {
            gridArea += cellRatio * 1;          // we assume that all nodes have height = 1
        })

        // prepare grid
        grid = [];
        gridRatio = size[0] / size[1];
        gridSize = [Math.ceil(Math.sqrt(gridRatio * gridArea)), Math.ceil(Math.sqrt(gridArea / gridRatio))];
        for (var i = 0; i < gridSize[0]; i++) {
            grid.push([]);
            for (var j = 0; j < gridSize[1]; j++) {
                grid[i].push(0);           // init of the grid with '0'
            }
        }

        // 1st loop; calculate positions
        node = context.next();
        nodes = [];
        index = 0;
        while (node && (index < cellRatios.length)) {
            node['aspectRatio'] = cellRatios[index];
            node['gridPosition'] = _calculatePosition([cellRatios[index], 1]);
            nodes.push(node);

            // Move to next renderable
            index++;
            node = context.next();
        }

        var canvasSize = _calculateCanvasSize();
        var scale = (size[0] / size[1] > canvasSize[0] / canvasSize[1]) ? size[1] / canvasSize[1] : size[0] / canvasSize[0];
        var widthAlign = _alignWidth(scale, size, canvasSize);
        var heightAlign = _alignHeight(scale, size, canvasSize);

        // 2nd loop; set node options
        nodes.forEach(function (node) {
            set.size = [node.aspectRatio * scale, 1 * scale];
            set.translate[0] = node.gridPosition[0] * scale + widthAlign[node.gridPosition[1]] * node.positionInRow;
            set.translate[1] = node.gridPosition[1] * scale + heightAlign * (node.gridPosition[1] + 1);
            context.set(node, set);
        })
    }

    FixedMasonryLayout.Capabilities = capabilities;
    module.exports = FixedMasonryLayout;
});

No comments:

Post a Comment