angular-justified-layout
Version:
Angularjs wrapper for Flickr Justified Layout
904 lines (700 loc) • 27.2 kB
JavaScript
/**
* Package: angular-justified-layout - v0.0.1
* Description: Angularjs wrapper for Flickr Justified Layout
* Last build: 2017-07-15
* @author codekraft-studio
* @license ISC
*/
require=(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
'use strict';
var merge = require('merge');
/**
* Row
* Wrapper for each row in a justified layout.
* Stores relevant values and provides methods for calculating layout of individual rows.
*
* @param {Object} layoutConfig - The same as that passed
* @param {Object} Initialization paramters. The following are all required:
* @param params.top {Number} Top of row, relative to container
* @param params.left {Number} Left side of row relative to container (equal to container left padding)
* @param params.width {Number} Width of row, not including container padding
* @param params.spacing {Number} Horizontal spacing between items
* @param params.targetRowHeight {Number} Layout algorithm will aim for this row height
* @param params.targetRowHeightTolerance {Number} Row heights may vary +/- (`targetRowHeight` x `targetRowHeightTolerance`)
* @param params.edgeCaseMinRowHeight {Number} Absolute minimum row height for edge cases that cannot be resolved within tolerance.
* @param params.edgeCaseMaxRowHeight {Number} Absolute maximum row height for edge cases that cannot be resolved within tolerance.
* @param params.isBreakoutRow {Boolean} Is this row in particular one of those breakout rows? Always false if it's not that kind of photo list
* @constructor
*/
var Row = module.exports = function (params) {
// Top of row, relative to container
this.top = params.top;
// Left side of row relative to container (equal to container left padding)
this.left = params.left;
// Width of row, not including container padding
this.width = params.width;
// Horizontal spacing between items
this.spacing = params.spacing;
// Row height calculation values
this.targetRowHeight = params.targetRowHeight;
this.targetRowHeightTolerance = params.targetRowHeightTolerance;
this.minAspectRatio = this.width / params.targetRowHeight * (1 - params.targetRowHeightTolerance);
this.maxAspectRatio = this.width / params.targetRowHeight * (1 + params.targetRowHeightTolerance);
// Edge case row height minimum/maximum
this.edgeCaseMinRowHeight = params.edgeCaseMinRowHeight || Number.NEGATIVE_INFINITY;
this.edgeCaseMaxRowHeight = params.edgeCaseMaxRowHeight || Number.POSITIVE_INFINITY;
// Layout direction
this.rightToLeft = params.rightToLeft;
// Full width breakout rows
this.isBreakoutRow = params.isBreakoutRow;
// Store layout data for each item in row
this.items = [];
// Height remains at 0 until it's been calculated
this.height = 0;
};
Row.prototype = {
/**
* Attempt to add a single item to the row.
* This is the heart of the justified algorithm.
* This method is direction-agnostic; it deals only with sizes, not positions.
*
* If the item fits in the row, without pushing row height beyond min/max tolerance,
* the item is added and the method returns true.
*
* If the item leaves row height too high, there may be room to scale it down and add another item.
* In this case, the item is added and the method returns true, but the row is incomplete.
*
* If the item leaves row height too short, there are too many items to fit within tolerance.
* The method will either accept or reject the new item, favoring the resulting row height closest to within tolerance.
* If the item is rejected, left/right padding will be required to fit the row height within tolerance;
* if the item is accepted, top/bottom cropping will be required to fit the row height within tolerance.
*
* @method addItem
* @param itemData {Object} Item layout data, containing item aspect ratio.
* @return {Boolean} True if successfully added; false if rejected.
*/
addItem: function addItem(itemData) {
var newItems = this.items.concat(itemData),
// Calculate aspect ratios for items only; exclude spacing
rowWidthWithoutSpacing = this.width - (newItems.length - 1) * this.spacing,
newAspectRatio = newItems.reduce(function (sum, item) {
return sum + item.aspectRatio;
}, 0),
targetAspectRatio = rowWidthWithoutSpacing / this.targetRowHeight,
previousRowWidthWithoutSpacing,
previousAspectRatio,
previousTargetAspectRatio;
// Handle big full-width breakout photos if we're doing them
if (this.isBreakoutRow) {
// Only do it if there's no other items in this row
if (this.items.length === 0) {
// Only go full width if this photo is a square or landscape
if (itemData.aspectRatio >= 1) {
// Close out the row with a full width photo
this.items.push(itemData);
this.completeLayout(rowWidthWithoutSpacing / itemData.aspectRatio);
return true;
}
}
}
if (newAspectRatio === 0) {
// Error state (item not added, row layout not complete);
// handled by consumer
return false;
}
if (newAspectRatio < this.minAspectRatio) {
// New aspect ratio is too narrow / scaled row height is too tall.
// Accept this item and leave row open for more items.
this.items.push(merge(itemData));
return true;
} else if (newAspectRatio > this.maxAspectRatio) {
// New aspect ratio is too wide / scaled row height will be too short.
// Accept item if the resulting aspect ratio is closer to target than it would be without the item.
// NOTE: Any row that falls into this block will require cropping/padding on individual items.
if (this.items.length === 0) {
// When there are no existing items, force acceptance of the new item and complete the layout.
// This is the pano special case.
this.items.push(merge(itemData));
this.completeLayout(rowWidthWithoutSpacing / newAspectRatio);
return true;
}
// Calculate width/aspect ratio for row before adding new item
previousRowWidthWithoutSpacing = this.width - (this.items.length - 1) * this.spacing;
previousAspectRatio = this.items.reduce(function (sum, item) {
return sum + item.aspectRatio;
}, 0);
previousTargetAspectRatio = previousRowWidthWithoutSpacing / this.targetRowHeight;
if (Math.abs(newAspectRatio - targetAspectRatio) > Math.abs(previousAspectRatio - previousTargetAspectRatio)) {
// Row with new item is us farther away from target than row without; complete layout and reject item.
this.completeLayout(previousRowWidthWithoutSpacing / previousAspectRatio);
return false;
} else {
// Row with new item is us closer to target than row without;
// accept the new item and complete the row layout.
this.items.push(merge(itemData));
this.completeLayout(rowWidthWithoutSpacing / newAspectRatio);
return true;
}
} else {
// New aspect ratio / scaled row height is within tolerance;
// accept the new item and complete the row layout.
this.items.push(merge(itemData));
this.completeLayout(rowWidthWithoutSpacing / newAspectRatio);
return true;
}
},
/**
* Check if a row has completed its layout.
*
* @method isLayoutComplete
* @return {Boolean} True if complete; false if not.
*/
isLayoutComplete: function isLayoutComplete() {
return this.height > 0;
},
/**
* Set row height and compute item geometry from that height.
* Will justify items within the row unless instructed not to.
*
* @method completeLayout
* @param newHeight {Number} Set row height to this value.
* @param justify Apply error correction to ensure photos exactly fill the row. Defaults to `true`.
*/
completeLayout: function completeLayout(newHeight, justify) {
var itemWidthSum = this.rightToLeft ? -this.left : this.left,
rowWidthWithoutSpacing = this.width - (this.items.length - 1) * this.spacing,
clampedToNativeRatio,
roundedHeight,
clampedHeight,
errorWidthPerItem,
roundedCumulativeErrors,
singleItemGeometry,
self = this;
// Justify unless explicitly specified otherwise.
if (typeof justify === 'undefined') {
justify = true;
}
// Don't set fractional values in the layout.
roundedHeight = Math.round(newHeight);
// Clamp row height to edge case minimum/maximum.
clampedHeight = Math.max(this.edgeCaseMinRowHeight, Math.min(roundedHeight, this.edgeCaseMaxRowHeight));
if (roundedHeight !== clampedHeight) {
// If row height was clamped, the resulting row/item aspect ratio will be off,
// so force it to fit the width (recalculate aspectRatio to match clamped height).
// NOTE: this will result in cropping/padding commensurate to the amount of clamping.
this.height = clampedHeight;
clampedToNativeRatio = rowWidthWithoutSpacing / clampedHeight / (rowWidthWithoutSpacing / roundedHeight);
} else {
// If not clamped, leave ratio at 1.0.
this.height = roundedHeight;
clampedToNativeRatio = 1.0;
}
// Compute item geometry based on newHeight.
this.items.forEach(function (item, i) {
item.top = self.top;
item.width = Math.round(item.aspectRatio * self.height * clampedToNativeRatio);
item.height = self.height;
if (self.rightToLeft) {
// Right-to-left.
item.left = self.width - itemWidthSum - item.width;
} else {
// Left-to-right.
item.left = itemWidthSum;
}
// Incrememnt width.
itemWidthSum += item.width + self.spacing;
});
// If specified, ensure items fill row and distribute error
// caused by rounding width and height across all items.
if (justify) {
// Left-to-right increments itemWidthSum differently;
// account for that before distributing error.
if (!this.rightToLeft) {
itemWidthSum -= this.spacing + this.left;
}
errorWidthPerItem = (itemWidthSum - this.width) / this.items.length;
roundedCumulativeErrors = this.items.map(function (item, i) {
return Math.round((i + 1) * errorWidthPerItem);
});
if (this.items.length === 1) {
// For rows with only one item, adjust item width to fill row.
singleItemGeometry = this.items[0];
singleItemGeometry.width -= Math.round(errorWidthPerItem);
// In right-to-left layouts, shift item to account for width change.
if (this.rightToLeft) {
singleItemGeometry.left += Math.round(errorWidthPerItem);
}
} else {
// For rows with multiple items, adjust item width and shift items to fill the row,
// while maintaining equal spacing between items in the row.
this.items.forEach(function (item, i) {
if (i > 0) {
item.left -= roundedCumulativeErrors[i - 1];
item.width -= roundedCumulativeErrors[i] - roundedCumulativeErrors[i - 1];
} else {
item.width -= roundedCumulativeErrors[i];
}
});
}
}
},
/**
* Force completion of row layout with current items.
*
* @method forceComplete
* @param fitToWidth {Boolean} Stretch current items to fill the row width.
* This will likely result in padding.
* @param fitToWidth {Number}
*/
forceComplete: function forceComplete(fitToWidth, rowHeight) {
var rowWidthWithoutSpacing = this.width - (this.items.length - 1) * this.spacing,
currentAspectRatio = this.items.reduce(function (sum, item) {
return sum + item.aspectRatio;
}, 0);
if (typeof rowHeight === 'number') {
this.completeLayout(rowHeight, false);
} else if (fitToWidth) {
// Complete using height required to fill row with current items.
this.completeLayout(rowWidthWithoutSpacing / currentAspectRatio);
} else {
// Complete using target row height.
this.completeLayout(this.targetRowHeight, false);
}
},
/**
* Return layout data for items within row.
* Note: returns actual list, not a copy.
*
* @method getItems
* @return Layout data for items within row.
*/
getItems: function getItems() {
return this.items;
}
};
},{"merge":2}],2:[function(require,module,exports){
/*!
* @name JavaScript/NodeJS Merge v1.2.0
* @author yeikos
* @repository https://github.com/yeikos/js.merge
* Copyright 2014 yeikos - MIT license
* https://raw.github.com/yeikos/js.merge/master/LICENSE
*/
;(function(isNode) {
/**
* Merge one or more objects
* @param bool? clone
* @param mixed,... arguments
* @return object
*/
var Public = function(clone) {
return merge(clone === true, false, arguments);
}, publicName = 'merge';
/**
* Merge two or more objects recursively
* @param bool? clone
* @param mixed,... arguments
* @return object
*/
Public.recursive = function(clone) {
return merge(clone === true, true, arguments);
};
/**
* Clone the input removing any reference
* @param mixed input
* @return mixed
*/
Public.clone = function(input) {
var output = input,
type = typeOf(input),
index, size;
if (type === 'array') {
output = [];
size = input.length;
for (index=0;index<size;++index)
output[index] = Public.clone(input[index]);
} else if (type === 'object') {
output = {};
for (index in input)
output[index] = Public.clone(input[index]);
}
return output;
};
/**
* Merge two objects recursively
* @param mixed input
* @param mixed extend
* @return mixed
*/
function merge_recursive(base, extend) {
if (typeOf(base) !== 'object')
return extend;
for (var key in extend) {
if (typeOf(base[key]) === 'object' && typeOf(extend[key]) === 'object') {
base[key] = merge_recursive(base[key], extend[key]);
} else {
base[key] = extend[key];
}
}
return base;
}
/**
* Merge two or more objects
* @param bool clone
* @param bool recursive
* @param array argv
* @return object
*/
function merge(clone, recursive, argv) {
var result = argv[0],
size = argv.length;
if (clone || typeOf(result) !== 'object')
result = {};
for (var index=0;index<size;++index) {
var item = argv[index],
type = typeOf(item);
if (type !== 'object') continue;
for (var key in item) {
var sitem = clone ? Public.clone(item[key]) : item[key];
if (recursive) {
result[key] = merge_recursive(result[key], sitem);
} else {
result[key] = sitem;
}
}
}
return result;
}
/**
* Get type of variable
* @param mixed input
* @return string
*
* @see http://jsperf.com/typeofvar
*/
function typeOf(input) {
return ({}).toString.call(input).slice(8, -1).toLowerCase();
}
if (isNode) {
module.exports = Public;
} else {
window[publicName] = Public;
}
})(typeof module === 'object' && module && typeof module.exports === 'object' && module.exports);
},{}],"justified-layout":[function(require,module,exports){
'use strict';
var merge = require('merge'),
Row = require('./row'),
layoutConfig = {},
layoutData = {},
currentRow = false;
/**
* Takes in a bunch of box data and config. Returns
* geometry to lay them out in a justified view.
*
* @method covertSizesToAspectRatios
* @param sizes {Array} Array of objects with widths and heights
* @return {Array} A list of aspect ratios
**/
module.exports = function (input) {
var config = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1];
// Defaults
var defaults = {
containerWidth: 1060,
containerPadding: 10,
boxSpacing: 10,
targetRowHeight: 320,
targetRowHeightTolerance: 0.25,
maxNumRows: Number.POSITIVE_INFINITY,
forceAspectRatio: false,
showWidows: true,
fullWidthBreakoutRowCadence: false
};
// Merge defaults and config passed in
layoutConfig = merge(defaults, config);
// Sort out padding and spacing values
var containerPadding = {};
var boxSpacing = {};
containerPadding.top = !isNaN(parseFloat(layoutConfig.containerPadding.top)) ? layoutConfig.containerPadding.top : layoutConfig.containerPadding;
containerPadding.right = !isNaN(parseFloat(layoutConfig.containerPadding.right)) ? layoutConfig.containerPadding.right : layoutConfig.containerPadding;
containerPadding.bottom = !isNaN(parseFloat(layoutConfig.containerPadding.bottom)) ? layoutConfig.containerPadding.bottom : layoutConfig.containerPadding;
containerPadding.left = !isNaN(parseFloat(layoutConfig.containerPadding.left)) ? layoutConfig.containerPadding.left : layoutConfig.containerPadding;
boxSpacing.horizontal = !isNaN(parseFloat(layoutConfig.boxSpacing.horizontal)) ? layoutConfig.boxSpacing.horizontal : layoutConfig.boxSpacing;
boxSpacing.vertical = !isNaN(parseFloat(layoutConfig.boxSpacing.vertical)) ? layoutConfig.boxSpacing.vertical : layoutConfig.boxSpacing;
layoutConfig.containerPadding = containerPadding;
layoutConfig.boxSpacing = boxSpacing;
// Local
layoutData._layoutItems = [];
layoutData._awakeItems = [];
layoutData._inViewportItems = [];
layoutData._leadingOrphans = [];
layoutData._trailingOrphans = [];
layoutData._containerHeight = layoutConfig.containerPadding.top;
layoutData._rows = [];
layoutData._orphans = [];
// Convert widths and heights to aspect ratios if we need to
return computeLayout(input.map(function (item) {
if (item.width && item.width) {
return { aspectRatio: item.width / item.height };
} else {
return { aspectRatio: item };
}
}));
};
/**
* Calculate the current layout for all items in the list that require layout.
* "Layout" means geometry: position within container and size
*
* @method computeLayout
* @param itemLayoutData {Array} Array of items to lay out, with data required to lay out each item
* @return {Object} The newly-calculated layout, containing the new container height, and lists of layout items
*/
function computeLayout(itemLayoutData) {
var notAddedNotComplete,
laidOutItems = [],
itemAdded,
currentRow,
nextToLastRowHeight;
// Apply forced aspect ratio if specified, and set a flag.
if (layoutConfig.forceAspectRatio) {
itemLayoutData.forEach(function (itemData) {
itemData.forcedAspectRatio = true;
itemData.aspectRatio = layoutConfig.forceAspectRatio;
});
}
// Loop through the items
itemLayoutData.some(function (itemData, i) {
notAddedNotComplete = false;
// If not currently building up a row, make a new one.
if (!currentRow) {
currentRow = createNewRow();
}
// Attempt to add item to the current row.
itemAdded = currentRow.addItem(itemData);
if (currentRow.isLayoutComplete()) {
// Row is filled; add it and start a new one
laidOutItems = laidOutItems.concat(addRow(currentRow));
if (layoutData._rows.length >= layoutConfig.maxNumRows) {
currentRow = null;
return true;
}
currentRow = createNewRow();
// Item was rejected; add it to its own row
if (!itemAdded) {
itemAdded = currentRow.addItem(itemData);
if (currentRow.isLayoutComplete()) {
// If the rejected item fills a row on its own, add the row and start another new one
laidOutItems = laidOutItems.concat(addRow(currentRow));
if (layoutData._rows.length >= layoutConfig.maxNumRows) {
currentRow = null;
return true;
}
currentRow = createNewRow();
} else if (!itemAdded) {
notAddedNotComplete = true;
}
}
} else {
if (!itemAdded) {
notAddedNotComplete = true;
}
}
});
// Handle any leftover content (orphans) depending on where they lie
// in this layout update, and in the total content set.
if (currentRow && currentRow.getItems().length && layoutConfig.showWidows) {
// Last page of all content or orphan suppression is suppressed; lay out orphans.
if (layoutData._rows.length) {
// Only Match previous row's height if it exists and it isn't a breakout row
if (layoutData._rows[layoutData._rows.length - 1].isBreakoutRow) {
nextToLastRowHeight = layoutData._rows[layoutData._rows.length - 1].targetRowHeight;
} else {
nextToLastRowHeight = layoutData._rows[layoutData._rows.length - 1].height;
}
currentRow.forceComplete(false, nextToLastRowHeight || layoutConfig.targetRowHeight);
} else {
// ...else use target height if there is no other row height to reference.
currentRow.forceComplete(false);
}
laidOutItems = laidOutItems.concat(addRow(currentRow));
}
// We need to clean up the bottom container padding
// First remove the height added for box spacing
layoutData._containerHeight = layoutData._containerHeight - (layoutConfig.boxSpacing.vertical || layoutConfig.boxSpacing);
// Then add our bottom container padding
layoutData._containerHeight = layoutData._containerHeight + (layoutConfig.containerPadding.bottom || layoutConfig.containerPadding);
return {
containerHeight: layoutData._containerHeight,
boxes: layoutData._layoutItems
};
}
/**
* Create a new, empty row.
*
* @method createNewRow
* @return A new, empty row of the type specified by this layout.
*/
function createNewRow() {
// Work out if this is a full width breakout row
if (layoutConfig.fullWidthBreakoutRowCadence !== false) {
if ((layoutData._rows.length + 1) % layoutConfig.fullWidthBreakoutRowCadence === 0) {
var isBreakoutRow = true;
}
}
return new Row({
top: layoutData._containerHeight,
left: layoutConfig.containerPadding.left,
width: layoutConfig.containerWidth - layoutConfig.containerPadding.left - layoutConfig.containerPadding.right,
spacing: layoutConfig.boxSpacing.horizontal,
targetRowHeight: layoutConfig.targetRowHeight,
targetRowHeightTolerance: layoutConfig.targetRowHeightTolerance,
edgeCaseMinRowHeight: 0.5 * layoutConfig.targetRowHeight,
edgeCaseMaxRowHeight: 2 * layoutConfig.targetRowHeight,
rightToLeft: false,
isBreakoutRow: isBreakoutRow
});
}
/**
* Add a completed row to the layout.
* Note: the row must have already been completed.
*
* @method addRow
* @param row {Row} The row to add.
* @return {Array} Each item added to the row.
*/
function addRow(row) {
layoutData._rows.push(row);
layoutData._layoutItems = layoutData._layoutItems.concat(row.getItems());
// Increment the container height
layoutData._containerHeight += row.height + layoutConfig.boxSpacing.vertical;
return row.items;
}
},{"./row":1,"merge":2}]},{},[]);
angular.module( 'angular-justified-layout', [] );
angular.module('angular-justified-layout')
.directive('justifiedLayout', function($log, $window) {
// TODO: put in a external service so it can be shared in app
var justifiedLayout = require('justified-layout');
// Init the configurations
var defaults = {
containerPadding: 5,
boxSpacing: 5
};
var directive = {
restrict: 'E',
replace: true,
transclude: true,
template: [
'<div class="justified-container">',
'<ng-transclude>',
'<div ng-repeat="item in items track by $index" class="box" ng-style="item.style">',
'<img ng-if="item.url" ng-src="{{ item.url }}" />',
'</div>',
'</ng-transclude>',
'</div>'
].join('\n'),
scope: {
items: '=',
options: '=?'
},
link: _link
};
return directive;
/**
* Append px string at the end of given value
*/
function _addPixels(value) {
return value + 'px';
}
/**
* Add the item style properties to every array objects
*/
function _addItemsStyle(items, geometry) {
// add new style to items
angular.forEach(items, function(item, index) {
// get the box measure object
var box = geometry.boxes[index];
// reference original item
var item = angular.isObject(item) ? item : {
width: box.width,
height: box.height,
};
var style = {
width: _addPixels(box.width),
height: _addPixels(box.height),
top: _addPixels(box.top),
left: _addPixels(box.left),
position: 'absolute'
};
// extend any user custom style
item.style = angular.isObject(item.style) ? angular.extend(item.style, style) : style;
// updte original item
items[index] = item;
});
}
/**
* Init the function and setup the watchers
*/
function _link(scope, elem, attrs, ctrl, transclude) {
// init some local variables
var geometry, collectionLength = 0;
// set container mandatory style
elem.css({
position: 'relative',
// overflow: 'hidden'
});
// get the directive options for justify
if( angular.isObject(scope.options) ) {
scope.options = angular.extend({}, defaults, scope.options)
} else {
scope.options = angular.copy(defaults);
}
// window resize callback
var onResize = function(e) {
// update the configurations object
scope.options.containerWidth = elem[0].clientWidth;
// get the boxes geometry using flickr code
geometry = justifiedLayout(scope.items, scope.options);
// TODO: move in a ng style so is auto watched
// update element height
elem.css({ height: _addPixels(geometry.containerHeight) });
_addItemsStyle(scope.items, geometry);
scope.$apply();
};
// collection change callback
var collectionWatch = function(newVal) {
// exit if input is not valid
if( !newVal || !newVal.length || !angular.isArray(newVal) ) {
return;
}
// TODO: Possibly make an option to disable deep watch which saves memory
// // do actions only if elements were added or removed (not updated)
// if( collectionLength === newVal.length ) {
// return;
// }
// update new collection (last) length value
collectionLength = newVal.length;
// get the element (container) width
scope.options.containerWidth = elem[0].clientWidth;
// get the boxes geometry using flickr code
geometry = justifiedLayout(scope.items, scope.options);
// TODO: move in a ng style so is auto watched
// update element height
elem.css({ height: _addPixels(geometry.containerHeight) });
_addItemsStyle(scope.items, geometry);
};
// watch for window resize
angular.element($window).on('resize', onResize);
/**
* Watch the array for changes and get the new geometries data
*/
scope.$watchCollection('items', collectionWatch);
/**
* When scope is destroyed remove all the listeners
*/
scope.$on('$destroy', function() {
angular.element($window).off('resize', onResize);
collectionWatch();
});
}
});