├── .editorconfig ├── .eslintrc ├── .gitignore ├── Gruntfile.js ├── README.md ├── bower.json ├── dist ├── angular-dynamic-layout.js └── angular-dynamic-layout.min.js ├── karma.conf.js ├── package.json ├── src ├── as.filter.js ├── custom-filter.filter.js ├── custom-ranker.filter.js ├── dynamic-layout.directive.js ├── filter.service.js ├── layout-on-load.directive.js ├── module.js ├── position.service.js └── ranker.service.js └── tests ├── .eslintrc ├── filter.spec.js ├── position.spec.js └── ranker.spec.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.json] 13 | indent_size = 2 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | }, 5 | "plugins": [ 6 | "angular" 7 | ], 8 | "rules": { 9 | "brace-style": [1, "1tbs"], 10 | "camelcase": [1, {"properties": "never"}], 11 | "consistent-return": 1, 12 | "indent": [2, 2], 13 | "new-cap": 0, 14 | "no-array-constructor": 2, 15 | "no-extra-parens": 1, 16 | "no-new-object": 2, 17 | "no-use-before-define": 0, 18 | "no-underscore-dangle": 0, 19 | "no-unexpected-multiline": 2, 20 | "no-unused-vars": 1, 21 | "one-var": [1, "never"], 22 | "quotes": [1, "single"], 23 | "space-after-keywords": 1, 24 | "space-before-blocks": 1, 25 | "space-before-function-paren": [1, "never"], 26 | "space-infix-ops": 1, 27 | "valid-jsdoc": 1 28 | }, 29 | "globals": { 30 | "angular": true, 31 | "_": true, 32 | "moment": true, 33 | "S": true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bower_components 3 | .idea 4 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | // Gruntfile.js 2 | 3 | // our wrapper function (required by grunt and its plugins) 4 | // all configuration goes inside this function 5 | module.exports = function (grunt) { 6 | 7 | // =========================================================================== 8 | // CONFIGURE GRUNT =========================================================== 9 | // =========================================================================== 10 | grunt.initConfig({ 11 | 12 | // get the configuration info from package.json ---------------------------- 13 | // this way we can use things like name and version (pkg.name) 14 | pkg: grunt.file.readJSON('package.json'), 15 | concat: { 16 | options: { 17 | separator: ';' 18 | }, 19 | dist: { 20 | src: ['src/module.js', 21 | 'src/dynamic-layout.directive.js', 22 | 'src/layout-on-load.directive.js', 23 | 'src/filter.service.js', 24 | 'src/position.service.js', 25 | 'src/ranker.service.js', 26 | 'src/as.filter.js', 27 | 'src/custom-filter.filter.js', 28 | 'src/custom-ranker.filter.js'], 29 | dest: 'dist/<%= pkg.name %>.js' 30 | } 31 | }, 32 | karma: { 33 | unit: { 34 | configFile: 'karma.conf.js', 35 | singleRun: true 36 | } 37 | }, 38 | jshint: { 39 | // when this task is run, lint the Gruntfile and all js files in src 40 | options: { 41 | multistr: true, 42 | }, 43 | build: ['Grunfile.js', 'src/**/*.js', 'tests/**/*.js'] 44 | }, 45 | uglify: { 46 | options: { 47 | banner: '/*\n <%= pkg.name %> <%= grunt.template.today("yyyy-mm-dd") %> \n*/\n', 48 | mangle: false 49 | }, 50 | build: { 51 | files: { 52 | 'dist/<%= pkg.name %>.min.js': ['dist/<%= pkg.name %>.js'], 53 | } 54 | } 55 | } 56 | }); 57 | 58 | // =========================================================================== 59 | // LOAD GRUNT PLUGINS ======================================================== 60 | // =========================================================================== 61 | // we can only load these if they are in our package.json 62 | // make sure you have run npm install so our app can find these 63 | grunt.loadNpmTasks('grunt-karma'); 64 | grunt.loadNpmTasks('grunt-contrib-jshint'); 65 | grunt.loadNpmTasks('grunt-contrib-concat'); 66 | grunt.loadNpmTasks('grunt-contrib-uglify'); 67 | grunt.registerTask('default', ['karma', 'jshint', 'concat', 'uglify']); 68 | }; 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [This project is looking for a collaborator to take over development as I no longer have the ressources to do so. If you have interested please let me know thanks ] 2 | 3 | # What is this? 4 | 5 | angular-dynamic-layout is an AngularJS dynamic grid layout. It is inspired from the successfull jQuery [isotope](http://isotope.metafizzy.co/) library and its underlying [masonry](http://masonry.desandro.com/) layout. 6 | 7 | Including jQuery isotope into AngularJS directives gets easily hacky, this library reproduces the layout behavior while taking advantage of `ngRepeat` filtering and ordering as well as `ngAnimate` module. 8 | 9 | It is meant to be a very light and customizable frame leaving a lot of freedom to the user, especially regarding templates, animations and overall design and responsiveness. 10 | 11 | This is a beta version and not production-ready. 12 | 13 | # Demo 14 | - [Demo](http://tristanguigue.github.io/angular-dynamic-layout) 15 | - [Demo Code](https://github.com/tristanguigue/angular-dynamic-layout/tree/gh-pages) 16 | 17 | # Installation 18 | 19 | ```` 20 | bower install angular-dynamic-layout 21 | ``` 22 | Or 23 | ```` 24 | npm install angular-dynamic-layout 25 | ``` 26 | # Usage 27 | Controller: 28 | ```` 29 | $scope.cards = [ 30 | { 31 | template : "app/partials/aboutMe.html", 32 | }, 33 | { 34 | template : "app/partials/businessCard.html", 35 | } 36 | ]; 37 | ```` 38 | Template: 39 | ```` 40 |
41 | ```` 42 | 43 | ## Cards 44 | 45 | Items must have a template property, this template will be dynamically included using the `ng-include` directive. 46 | 47 | - Content: the templates' HTML content is entirely up to you. 48 | 49 | - Controller: each card can have a specific controller, you can also have a common controller for all cards. For example: 50 | ```` 51 | 52 |
53 | My Card 54 |
55 | ```` 56 | 57 | - External Scope: to reach the directive's parent controller you can use `externalScope()` in the cards' template. 58 | 59 | - Data: you can provide any data to your templates from the list of cards, for example: 60 | 61 | Contoller: 62 | ```` 63 | $scope.cards = [ 64 | { 65 | template : "app/partials/work1.html", 66 | tabs : ["home", "work"], 67 | data : { 68 | "position" : "Web Developer", 69 | "company" : "Hacker Inc." 70 | }, 71 | added : 1414871272105, 72 | }, 73 | { 74 | template : "app/partials/work1.html", 75 | tabs : ["home", "work"], 76 | data : { 77 | "position" : "Data Scientist", 78 | "company" : "Big Data Inc." 79 | }, 80 | added : 1423871272105, 81 | } 82 | ]; 83 | ```` 84 | Template: 85 | ```` 86 |
87 |
88 |

Position

89 |

{{it.data.position}}

90 |
91 |
92 |

Company

93 |

{{it.data.company}}

94 |
95 |
96 | ```` 97 | 98 | ## Responsiveness 99 | To make your layout responsive just create a set of media queries that ajust the size of your cards and of the container. dynamic-layout will find the container and cards widths on its own. A layout will be triggered when the screen's size changes. 100 | 101 | ## Animations 102 | The animations are based on the `ngAnimate` module, they are entirely up to you and set in the CSS. 103 | Here are the available animations: 104 | 105 | - `move-items-animation`: use this class to animate the movement of the cards between two positions, for example: 106 | ```` 107 | .move-items-animation{ 108 | transition-property: left, top; 109 | transition-duration: 1s; 110 | transition-timing-function: ease-in-out; 111 | } 112 | ```` 113 | - `ng-enter` and `ng-leave`: you can animate the entering and leaving of your cards in the grid, for example when applying filters: 114 | 115 | ```` 116 | .dynamic-layout-item-parent.ng-enter{ 117 | transition: .5s ease-in-out; 118 | opacity:0; 119 | } 120 | .dynamic-layout-item-parent.ng-enter.ng-enter-active{ 121 | opacity:1; 122 | } 123 | 124 | .dynamic-layout-item-parent.ng-leave{ 125 | transition: .5s ease-in-out; 126 | opacity:1; 127 | } 128 | .dynamic-layout-item-parent.ng-leave.ng-leave-active{ 129 | opacity:0; 130 | } 131 | ```` 132 | 133 | ## Features 134 | 135 | ### Filtering 136 | You can provide and update a list of filters like this: 137 | ```` 138 |
139 | ```` 140 | Those filters needs to be in the [Conjuctive Normal Form](http://en.wikipedia.org/wiki/Conjunctive_normal_form). Basically a list of and groups composed of or groups. Each statement contains the property to be evaluated, a comparator and the value(s) allowed. For example: 141 | ```` 142 | var filters = [ // an AND group compose of OR groups 143 | [ // an OR group compose of statements 144 | ['color', '=', 'grey'], // A statement 145 | ['color', '=', 'black'] 146 | ], 147 | [ // a second OR group composed of statements 148 | ['atomicNumber', '<', 3] 149 | ] 150 | ]; 151 | ```` 152 | The list of comparators available are: 153 | ```` 154 | ['=', '<', '>', '<=', '>=', '!=', 'in', 'not in', 'contains'] 155 | ```` 156 | #### Custom filters 157 | 158 | You can make your own filters by providing any function that takes the item as input and returns a boolean. For example: 159 | ```` 160 | var myCustomFilter = function(item){ 161 | if(item.color != 'red') 162 | return true; 163 | else 164 | return false; 165 | }; 166 | 167 | filters = [ 168 | [myCustomFilter] 169 | ]; 170 | ```` 171 | 172 | ### Sorting 173 | You can provide and update a list of rankers like this: 174 | ```` 175 |
176 | ```` 177 | Each ranker contains the property to be evaluated and the order. If two items are the same regarding the first ranker, the second one is used to part them, etc. 178 | ```` 179 | var rankers = [ 180 | ["color", "asc"], 181 | ["atomicNumber", "desc"] 182 | ]; 183 | ```` 184 | #### Custom rankers 185 | 186 | You can make your own ranker by providing any function that takes the item as input and returns a value to be evaluated. For example: 187 | 188 | ```` 189 | var myCustomGetter = function(item){ 190 | if(item.atomicNumber > 5) return 1; 191 | else return 0; 192 | }; 193 | 194 | rankers = [ 195 | [myCustomGetter, "asc"] 196 | ]; 197 | ```` 198 | 199 | ### Adding and removing items 200 | 201 | You can add or remove any items from the cards list controller and the dynamicLayout directive will detect it. For example: 202 | ```` 203 | $scope.cards.splice(index, 1); 204 | ```` 205 | 206 | ### Triggering layout and callback 207 | If a card is modified in any way (expanded for example) you can trigger a layout by broacasting in the `$rootScope`. Once the animations are completed the callback will be executed. 208 | 209 | ```` 210 | $scope.toggleText = function(){ 211 | $scope.showingMoreText = !$scope.showingMoreText; 212 | // We need to broacast the layout on the next digest once the text 213 | // is actually shown 214 | $timeout(function(){ 215 | $rootScope.$broadcast("layout", function(){ 216 | // The layout animations have completed 217 | }); 218 | }); 219 | } 220 | ```` 221 | 222 | 223 | 224 | 225 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-dynamic-layout", 3 | "version": "0.1.11", 4 | "main": "dist/angular-dynamic-layout.min.js", 5 | "dependencies": { 6 | "angular": "1.3.5", 7 | "angular-animate": "1.3.5" 8 | }, 9 | "devDependencies": { 10 | "angular-mocks": "1.3.5" 11 | }, 12 | "homepage": "https://github.com/tristanguigue/angular-dynamic-layout", 13 | "authors": [ 14 | "Tristan Guigue " 15 | ], 16 | "repository": { 17 | "type": "git", 18 | "url": "git://github.com/tristanguigue/angular-dynamic-layout.git" 19 | }, 20 | "description": "An angularJS approach to dynamic grid", 21 | "license": "MIT", 22 | "ignore": [ 23 | "**/.*", 24 | "node_modules", 25 | "bower_components", 26 | "tests" 27 | ], 28 | "private": false 29 | } 30 | -------------------------------------------------------------------------------- /dist/angular-dynamic-layout.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | angular 5 | .module('dynamicLayout', [ 'ngAnimate' ]); 6 | 7 | })(); 8 | ;(function() { 9 | 'use strict'; 10 | 11 | angular 12 | .module('dynamicLayout') 13 | .directive('dynamicLayout', ['$timeout', '$window', '$q', '$animate', 'PositionService', dynamicLayout]); 14 | 15 | /* 16 | * The isotope directive that renders the templates based on the array of items 17 | * passed 18 | * @scope items: the list of items to be rendered 19 | * @scope rankers: the rankers to be applied on the list of items 20 | * @scope filters: the filters to be applied on the list of items 21 | * @scope defaulttemplate: (optional) the deafult template to be applied on each item if no item template is defined 22 | */ 23 | function dynamicLayout($timeout, $window, $q, $animate, PositionService) { 24 | 25 | return { 26 | restrict: 'A', 27 | scope: { 28 | items: '=', 29 | rankers: '=', 30 | filters: '=', 31 | defaulttemplate: '=?' 32 | }, 33 | template: '
', 41 | link: link 42 | }; 43 | 44 | function link(scope, element) { 45 | 46 | // Keep count of the number of templates left to load 47 | scope.templatesToLoad = 0; 48 | scope.externalScope = externalScope; 49 | 50 | // Fires when a template is requested through the ng-include directive 51 | scope.$on('$includeContentRequested', function() { 52 | scope.templatesToLoad++; 53 | }); 54 | 55 | // Fires when a template has been loaded through the ng-include 56 | // directive 57 | scope.$on('$includeContentLoaded', function() { 58 | scope.templatesToLoad--; 59 | }); 60 | 61 | /* 62 | * Triggers a layout every time the items are changed 63 | */ 64 | scope.$watch('filteredItems', function(newValue, oldValue) { 65 | // We want the filteredItems to be available to the controller 66 | // This feels hacky, there must be a better way to do this 67 | scope.$parent.filteredItems = scope.filteredItems; 68 | 69 | if (!angular.equals(newValue, oldValue)) { 70 | itemsLoaded().then(function() { 71 | layout(); 72 | }); 73 | } 74 | }, true); 75 | 76 | /* 77 | * Triggers a layout every time the window is resized 78 | */ 79 | angular.element($window).bind('resize', function() { 80 | // We need to apply the scope 81 | scope.$apply(function() { 82 | layout(); 83 | }); 84 | }); 85 | 86 | /* 87 | * Triggers a layout whenever requested by an external source 88 | * Allows a callback to be fired after the layout animation is 89 | * completed 90 | */ 91 | scope.$on('layout', function(event, callback) { 92 | layout().then(function() { 93 | if (angular.isFunction('function')) { 94 | callback(); 95 | } 96 | }); 97 | }); 98 | 99 | /* 100 | * Triggers the initial layout once all the templates are loaded 101 | */ 102 | itemsLoaded().then(function() { 103 | layout(); 104 | }); 105 | 106 | /* 107 | * Use the PositionService to layout the items 108 | * @return the promise of the cards being animated 109 | */ 110 | function layout() { 111 | return PositionService.layout(element[0].offsetWidth); 112 | } 113 | 114 | /* 115 | * Check when all the items have been loaded by the ng-include 116 | * directive 117 | */ 118 | function itemsLoaded() { 119 | var def = $q.defer(); 120 | 121 | // $timeout : We need to wait for the includeContentRequested to 122 | // be called before we can assume there is no templates to be loaded 123 | $timeout(function() { 124 | if (scope.templatesToLoad === 0) { 125 | def.resolve(); 126 | } 127 | }); 128 | 129 | scope.$watch('templatesToLoad', function(newValue, oldValue) { 130 | if (newValue !== oldValue && scope.templatesToLoad === 0) { 131 | def.resolve(); 132 | } 133 | }); 134 | 135 | return def.promise; 136 | } 137 | 138 | /* 139 | * This allows the external scope, that is the scope of 140 | * dynamic-layout's container to be called from the templates 141 | * @return the given scope 142 | */ 143 | function externalScope() { 144 | return scope.$parent; 145 | } 146 | 147 | } 148 | } 149 | 150 | })(); 151 | ;(function() { 152 | 'use strict'; 153 | 154 | angular 155 | .module('dynamicLayout') 156 | .directive('layoutOnLoad', ['$rootScope', layoutOnLoad]); 157 | 158 | /* 159 | * Directive on images to layout after each load 160 | */ 161 | function layoutOnLoad($rootScope) { 162 | 163 | return { 164 | restrict: 'A', 165 | link: function(scope, element) { 166 | element.bind('load error', function() { 167 | $rootScope.$broadcast('layout'); 168 | }); 169 | } 170 | }; 171 | } 172 | 173 | })(); 174 | ;(function() { 175 | 'use strict'; 176 | 177 | angular 178 | .module('dynamicLayout') 179 | .factory('FilterService', FilterService); 180 | 181 | /* 182 | * The filter service 183 | * 184 | * COMPARATORS = ['=', '<', '>', '<=', '>=', '!=', 'in', 'not in', 'contains'] 185 | * 186 | * Allows filters in Conjuctive Normal Form using the item's property or any 187 | * custom operation on the items 188 | * 189 | * For example: 190 | * var filters = [ // an AND goup compose of OR groups 191 | * [ // an OR group compose of statements 192 | * ['color', '=', 'grey'], // A statement 193 | * ['color', '=', 'black'] 194 | * ], 195 | * [ // a second OR goup composed of statements 196 | * ['atomicNumber', '<', 3] 197 | * ] 198 | * ]; 199 | * Or 200 | * var myCustomFilter = function(item){ 201 | * if(item.color != 'red') 202 | * return true; 203 | * else 204 | * return false; 205 | * }; 206 | * 207 | * filters = [ 208 | * [myCustomFilter] 209 | * ]; 210 | * 211 | */ 212 | function FilterService() { 213 | 214 | return { 215 | applyFilters: applyFilters 216 | }; 217 | 218 | /* 219 | * Check which of the items passes the filters 220 | * @param items: the items being probed 221 | * @param filters: the array of and groups use to probe the item 222 | * @return the list of items that passes the filters 223 | */ 224 | function applyFilters(items, filters) { 225 | var retItems = []; 226 | var i; 227 | for (i in items) { 228 | if (checkAndGroup(items[i], filters)) { 229 | retItems.push(items[i]); 230 | } 231 | } 232 | return retItems; 233 | } 234 | 235 | /* 236 | * Check if a single item passes the single statement criteria 237 | * @param item: the item being probed 238 | * @param statement: the criteria being use to test the item 239 | * @return true if the item passed the statement, false otherwise 240 | */ 241 | function checkStatement(item, statement) { 242 | // If the statement is a custom filter, we give the item as a parameter 243 | if (angular.isFunction(statement)) { 244 | return statement(item); 245 | } 246 | 247 | // If the statement is a regular filter, it has to be with the form 248 | // [propertyName, comparator, value] 249 | 250 | var STATEMENT_LENGTH = 3; 251 | if (statement.length < STATEMENT_LENGTH) { 252 | throw 'Incorrect statement'; 253 | } 254 | 255 | var property = statement[0]; 256 | var comparator = statement[1]; 257 | var value = statement[2]; 258 | 259 | // If the property is not found in the item then we consider the 260 | // statement to be false 261 | if (!item[property]) { 262 | return false; 263 | } 264 | 265 | switch (comparator) { 266 | case '=': 267 | return item[property] === value; 268 | case '<': 269 | return item[property] < value; 270 | case '<=': 271 | return item[property] <= value; 272 | case '>': 273 | return item[property] > value; 274 | case '>=': 275 | return item[property] >= value; 276 | case '!=': 277 | return item[property] !== value; 278 | case 'in': 279 | return item[property] in value; 280 | case 'not in': 281 | return !(item[property] in value); 282 | case 'contains': 283 | if (!(item[property] instanceof Array)) { 284 | throw 'contains statement has to be applied on array'; 285 | } 286 | return item[property].indexOf(value) > -1; 287 | default: 288 | throw 'Incorrect statement comparator: ' + comparator; 289 | } 290 | } 291 | 292 | /* 293 | * Check a sub (or) group 294 | * @param item: the item being probed 295 | * @param orGroup: the array of statement use to probe the item 296 | * @return true if the item passed at least one of the statements, 297 | * false otherwise 298 | */ 299 | function checkOrGroup(item, orGroup) { 300 | var j; 301 | for (j in orGroup) { 302 | if (checkStatement(item, orGroup[j])) { 303 | return true; 304 | } 305 | } 306 | return false; 307 | } 308 | 309 | /* 310 | * Check the main group 311 | * @param item: the item being probed 312 | * @param orGroup: the array of or groups use to probe the item 313 | * @return true if the item passed all of of the or groups, 314 | * false otherwise 315 | */ 316 | function checkAndGroup(item, andGroup) { 317 | var i; 318 | for (i in andGroup) { 319 | if (!checkOrGroup(item, andGroup[i])) { 320 | return false; 321 | } 322 | } 323 | return true; 324 | } 325 | 326 | } 327 | 328 | })(); 329 | ;(function() { 330 | 'use strict'; 331 | 332 | angular 333 | .module('dynamicLayout') 334 | .factory('PositionService', ['$window', '$document', '$animate', '$timeout', '$q', PositionService]); 335 | 336 | /* 337 | * The position service 338 | * 339 | * Find the best adjustements of the elemnts in the DOM according the their 340 | * order, height and width 341 | * 342 | * Fix their absolute position in the DOM while adding a ng-animate class for 343 | * personalized animations 344 | * 345 | */ 346 | function PositionService($window, $document, $animate, $timeout, $q) { 347 | 348 | // The list of ongoing animations 349 | var ongoingAnimations = {}; 350 | // The list of items related to the DOM elements 351 | var items = []; 352 | // The list of the DOM elements 353 | var elements = []; 354 | // The columns that contains the items 355 | var columns = []; 356 | 357 | var self = { 358 | getItemsDimensionFromDOM: getItemsDimensionFromDOM, 359 | applyToDOM: applyToDOM, 360 | layout: layout, 361 | getColumns: getColumns 362 | }; 363 | return self; 364 | 365 | /* 366 | * Get the items heights and width from the DOM 367 | * @return: the list of items with their sizes 368 | */ 369 | function getItemsDimensionFromDOM() { 370 | // not(.ng-leave) : we don't want to select elements that have been 371 | // removed but are still in the DOM 372 | elements = $document[0].querySelectorAll( 373 | '.dynamic-layout-item-parent:not(.ng-leave)' 374 | ); 375 | items = []; 376 | for (var i = 0; i < elements.length; ++i) { 377 | // Note: we need to get the children element width because that's 378 | // where the style is applied 379 | var rect = elements[i].children[0].getBoundingClientRect(); 380 | var width; 381 | var height; 382 | if (rect.width) { 383 | width = rect.width; 384 | height = rect.height; 385 | } else { 386 | width = rect.right - rect.left; 387 | height = rect.top - rect.bottom; 388 | } 389 | 390 | items.push({ 391 | height: height + 392 | parseFloat($window.getComputedStyle(elements[i]).marginTop), 393 | width: width + 394 | parseFloat( 395 | $window.getComputedStyle(elements[i].children[0]).marginLeft 396 | ) 397 | }); 398 | } 399 | return items; 400 | } 401 | 402 | /* 403 | * Apply positions to the DOM with an animation 404 | * @return: the promise of the position animations being completed 405 | */ 406 | function applyToDOM() { 407 | 408 | var ret = $q.defer(); 409 | 410 | /* 411 | * Launch an animation on a specific element 412 | * Once the animation is complete remove it from the ongoing animation 413 | * @param element: the element being moved 414 | * @param i: the index of the current animation 415 | * @return: the promise of the animation being completed 416 | */ 417 | function launchAnimation(element, i) { 418 | var animationPromise = $animate.addClass(element, 419 | 'move-items-animation', 420 | { 421 | from: { 422 | position: 'absolute' 423 | }, 424 | to: { 425 | left: items[i].x + 'px', 426 | top: items[i].y + 'px' 427 | } 428 | } 429 | ); 430 | 431 | animationPromise.then(function() { 432 | // We remove the class so that the animation can be ran again 433 | element.classList.remove('move-items-animation'); 434 | delete ongoingAnimations[i]; 435 | }); 436 | 437 | return animationPromise; 438 | } 439 | 440 | /* 441 | * Launch the animations on all the elements 442 | * @return: the promise of the animations being completed 443 | */ 444 | function launchAnimations() { 445 | var i; 446 | for (i = 0; i < items.length; ++i) { 447 | // We need to pass the specific element we're dealing with 448 | // because at the next iteration elements[i] might point to 449 | // something else 450 | ongoingAnimations[i] = launchAnimation(elements[i], i); 451 | } 452 | $q.all(ongoingAnimations).then(function() { 453 | ret.resolve(); 454 | }); 455 | } 456 | 457 | // We need to cancel all ongoing animations before we start the new 458 | // ones 459 | if (Object.keys(ongoingAnimations).length) { 460 | for (var j in ongoingAnimations) { 461 | $animate.cancel(ongoingAnimations[j]); 462 | delete ongoingAnimations[j]; 463 | } 464 | } 465 | 466 | // For some reason we need to launch the new animations at the next 467 | // digest 468 | $timeout(function() { 469 | launchAnimations(ret); 470 | }); 471 | 472 | return ret.promise; 473 | } 474 | 475 | /* 476 | * Apply the position service on the elements in the DOM 477 | * @param containerWidth: the width of the dynamic-layout container 478 | * @return: the promise of the position animations being completed 479 | */ 480 | function layout(containerWidth) { 481 | // We first gather the items dimension based on the DOM elements 482 | items = self.getItemsDimensionFromDOM(); 483 | 484 | // Then we get the column size base the elements minimum width 485 | var colSize = getColSize(); 486 | var nbColumns = Math.floor(containerWidth / colSize); 487 | // We create empty columns to be filled with the items 488 | initColumns(nbColumns); 489 | 490 | // We determine what is the column size of each of the items based on 491 | // their width and the column size 492 | setItemsColumnSpan(colSize); 493 | 494 | // We set what should be their absolute position in the DOM 495 | setItemsPosition(columns, colSize); 496 | 497 | // We apply those positions to the DOM with an animation 498 | return self.applyToDOM(); 499 | } 500 | 501 | // Make the columns public 502 | function getColumns() { 503 | return columns; 504 | } 505 | 506 | /* 507 | * Intialize the columns 508 | * @param nb: the number of columns to be initialized 509 | * @return: the empty columns 510 | */ 511 | function initColumns(nb) { 512 | columns = []; 513 | var i; 514 | for (i = 0; i < nb; ++i) { 515 | columns.push([]); 516 | } 517 | return columns; 518 | } 519 | 520 | /* 521 | * Get the columns heights 522 | * @param columns: the columns with the items they contain 523 | * @return: an array of columns heights 524 | */ 525 | function getColumnsHeights(cols) { 526 | var columnsHeights = []; 527 | var i; 528 | for (i in cols) { 529 | var h; 530 | if (cols[i].length) { 531 | var lastItem = cols[i][cols[i].length - 1]; 532 | h = lastItem.y + lastItem.height; 533 | } else { 534 | h = 0; 535 | } 536 | columnsHeights.push(h); 537 | } 538 | return columnsHeights; 539 | } 540 | 541 | /* 542 | * Find the item absolute position and what columns it belongs too 543 | * @param item: the item to place 544 | * @param colHeights: the current heigh of the column when all items prior to this 545 | * one were places 546 | * @param colSize: the column size 547 | * @return the item's columms and coordinates 548 | */ 549 | function getItemColumnsAndPosition(item, colHeights, colSize) { 550 | if (item.columnSpan > colHeights.length) { 551 | throw 'Item too large'; 552 | } 553 | 554 | var indexOfMin = 0; 555 | var minFound = 0; 556 | var i; 557 | 558 | // We look at what set of columns have the minimum height 559 | for (i = 0; i <= colHeights.length - item.columnSpan; ++i) { 560 | var startingColumn = i; 561 | var endingColumn = i + item.columnSpan; 562 | var maxHeightInPart = Math.max.apply( 563 | Math, colHeights.slice(startingColumn, endingColumn) 564 | ); 565 | 566 | if (i === 0 || maxHeightInPart < minFound) { 567 | minFound = maxHeightInPart; 568 | indexOfMin = i; 569 | } 570 | } 571 | 572 | var itemColumns = []; 573 | for (i = indexOfMin; i < indexOfMin + item.columnSpan; ++i) { 574 | itemColumns.push(i); 575 | } 576 | 577 | var position = { 578 | x: itemColumns[0] * colSize, 579 | y: minFound 580 | }; 581 | 582 | return { 583 | columns: itemColumns, 584 | position: position 585 | }; 586 | } 587 | 588 | /* 589 | * Set the items' absolute position 590 | * @param columns: the empty columns 591 | * @param colSize: the column size 592 | */ 593 | function setItemsPosition(cols, colSize) { 594 | var i; 595 | var j; 596 | for (i = 0; i < items.length; ++i) { 597 | var columnsHeights = getColumnsHeights(cols); 598 | 599 | var itemColumnsAndPosition = getItemColumnsAndPosition(items[i], 600 | columnsHeights, 601 | colSize); 602 | 603 | // We place the item in the found columns 604 | for (j in itemColumnsAndPosition.columns) { 605 | columns[itemColumnsAndPosition.columns[j]].push(items[i]); 606 | } 607 | 608 | items[i].x = itemColumnsAndPosition.position.x; 609 | items[i].y = itemColumnsAndPosition.position.y; 610 | } 611 | } 612 | 613 | /* 614 | * Get the column size based on the minimum width of the items 615 | * @return: column size 616 | */ 617 | function getColSize() { 618 | var colSize; 619 | var i; 620 | for (i = 0; i < items.length; ++i) { 621 | if (!colSize || items[i].width < colSize) { 622 | colSize = items[i].width; 623 | } 624 | } 625 | return colSize; 626 | } 627 | 628 | /* 629 | * Set the column span for each of the items based on their width and the 630 | * column size 631 | * @param: column size 632 | */ 633 | function setItemsColumnSpan(colSize) { 634 | var i; 635 | for (i = 0; i < items.length; ++i) { 636 | items[i].columnSpan = Math.ceil(items[i].width / colSize); 637 | } 638 | } 639 | 640 | } 641 | 642 | })(); 643 | ;(function() { 644 | 'use strict'; 645 | 646 | angular 647 | .module('dynamicLayout') 648 | .factory('RankerService', RankerService); 649 | 650 | /* 651 | * The rankers service 652 | * 653 | * Allows a list of rankers to sort the items. 654 | * If two items are the same regarding the first ranker, the second one is used 655 | * to part them, etc. 656 | * 657 | * Rankers can be either a property name or a custom operation on the item. 658 | * They all need to specify the order chosen (asc' or 'desc') 659 | * 660 | * var rankers = [ 661 | * ['color', 'asc'], 662 | * ['atomicNumber', 'desc'] 663 | * ]; 664 | * Or 665 | * var rankers = [ 666 | * [myCustomGetter, 'asc'] 667 | * ]; 668 | * 669 | */ 670 | function RankerService() { 671 | 672 | return { 673 | applyRankers: applyRankers 674 | }; 675 | 676 | /* 677 | * Order the items with the given rankers 678 | * @param items: the items being ranked 679 | * @param rankers: the array of rankers used to rank the items 680 | * @return the ordered list of items 681 | */ 682 | function applyRankers(items, rankers) { 683 | // The ranker counter 684 | var i = 0; 685 | 686 | if (rankers) { 687 | items.sort(sorter); 688 | } 689 | 690 | /* 691 | * The custom sorting function using the built comparison function 692 | * @param a, b: the items to be compared 693 | * @return -1, 0 or 1 694 | */ 695 | function sorter(a, b) { 696 | i = 0; 697 | return recursiveRanker(a, b); 698 | } 699 | 700 | /* 701 | * Compare recursively two items 702 | * It first compare the items with the first ranker, if no conclusion 703 | * can be drawn it uses the second ranker and so on until it finds a 704 | * winner or there are no more rankers 705 | * @param a, b: the items to be compared 706 | * @return -1, 0 or 1 707 | */ 708 | function recursiveRanker(a, b) { 709 | var ranker = rankers[i][0]; 710 | var ascDesc = rankers[i][1]; 711 | var valueA; 712 | var valueB; 713 | // If it is a custom ranker, give the item as input and gather the 714 | // ouput 715 | if (angular.isFunction(ranker)) { 716 | valueA = ranker(a); 717 | valueB = ranker(b); 718 | } else { 719 | // Otherwise use the item's properties 720 | if (!(ranker in a) && !(ranker in b)) { 721 | valueA = 0; 722 | valueB = 0; 723 | } else if (!(ranker in a)) { 724 | return ascDesc === 'asc' ? -1 : 1; 725 | } else if (!(ranker in b)) { 726 | return ascDesc === 'asc' ? 1 : -1; 727 | } 728 | valueA = a[ranker]; 729 | valueB = b[ranker]; 730 | } 731 | 732 | if (typeof valueA === typeof valueB) { 733 | 734 | if (angular.isString(valueA)) { 735 | var comp = valueA.localeCompare(valueB); 736 | if (comp === 1) { 737 | return ascDesc === 'asc' ? 1 : -1; 738 | } else if (comp === -1) { 739 | return ascDesc === 'asc' ? -1 : 1; 740 | } 741 | } else { 742 | if (valueA > valueB) { 743 | return ascDesc === 'asc' ? 1 : -1; 744 | } else if (valueA < valueB) { 745 | return ascDesc === 'asc' ? -1 : 1; 746 | } 747 | } 748 | } 749 | 750 | ++i; 751 | 752 | if (rankers.length > i) { 753 | return recursiveRanker(a, b); 754 | } 755 | 756 | return 0; 757 | } 758 | 759 | return items; 760 | } 761 | 762 | } 763 | 764 | })(); 765 | ;(function() { 766 | 'use strict'; 767 | 768 | angular 769 | .module('dynamicLayout') 770 | .filter('as', ['$parse', as]); 771 | 772 | /* 773 | * This allowed the result of the filters to be assigned to the scope 774 | */ 775 | function as($parse) { 776 | 777 | return function(value, context, path) { 778 | $parse(path).assign(context, value); 779 | return value; 780 | }; 781 | } 782 | 783 | })(); 784 | ;(function() { 785 | 'use strict'; 786 | 787 | angular 788 | .module('dynamicLayout') 789 | .filter('customFilter', ['FilterService', customFilter]); 790 | 791 | /* 792 | * The filter to be applied on the ng-repeat directive 793 | */ 794 | function customFilter(FilterService) { 795 | 796 | return function(items, filters) { 797 | if (filters) { 798 | return FilterService.applyFilters(items, filters); 799 | } 800 | return items; 801 | }; 802 | } 803 | 804 | })(); 805 | ;(function() { 806 | 'use strict'; 807 | 808 | angular 809 | .module('dynamicLayout') 810 | .filter('customRanker', ['RankerService', customRanker]); 811 | 812 | /* 813 | * The ranker to be applied on the ng-repeat directive 814 | */ 815 | function customRanker(RankerService) { 816 | 817 | return function(items, rankers) { 818 | if (rankers) { 819 | return RankerService.applyRankers(items, rankers); 820 | } 821 | return items; 822 | }; 823 | } 824 | 825 | })(); 826 | -------------------------------------------------------------------------------- /dist/angular-dynamic-layout.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | angular-dynamic-layout 2015-09-13 3 | */ 4 | !function(){"use strict";angular.module("dynamicLayout",["ngAnimate"])}(),function(){"use strict";function dynamicLayout($timeout,$window,$q,$animate,PositionService){function link(scope,element){function layout(){return PositionService.layout(element[0].offsetWidth)}function itemsLoaded(){var def=$q.defer();return $timeout(function(){0===scope.templatesToLoad&&def.resolve()}),scope.$watch("templatesToLoad",function(newValue,oldValue){newValue!==oldValue&&0===scope.templatesToLoad&&def.resolve()}),def.promise}function externalScope(){return scope.$parent}scope.templatesToLoad=0,scope.externalScope=externalScope,scope.$on("$includeContentRequested",function(){scope.templatesToLoad++}),scope.$on("$includeContentLoaded",function(){scope.templatesToLoad--}),scope.$watch("filteredItems",function(newValue,oldValue){scope.$parent.filteredItems=scope.filteredItems,angular.equals(newValue,oldValue)||itemsLoaded().then(function(){layout()})},!0),angular.element($window).bind("resize",function(){scope.$apply(function(){layout()})}),scope.$on("layout",function(event,callback){layout().then(function(){angular.isFunction("function")&&callback()})}),itemsLoaded().then(function(){layout()})}return{restrict:"A",scope:{items:"=",rankers:"=",filters:"=",defaulttemplate:"=?"},template:'
',link:link}}angular.module("dynamicLayout").directive("dynamicLayout",["$timeout","$window","$q","$animate","PositionService",dynamicLayout])}(),function(){"use strict";function layoutOnLoad($rootScope){return{restrict:"A",link:function(scope,element){element.bind("load error",function(){$rootScope.$broadcast("layout")})}}}angular.module("dynamicLayout").directive("layoutOnLoad",["$rootScope",layoutOnLoad])}(),function(){"use strict";function FilterService(){function applyFilters(items,filters){var i,retItems=[];for(i in items)checkAndGroup(items[i],filters)&&retItems.push(items[i]);return retItems}function checkStatement(item,statement){if(angular.isFunction(statement))return statement(item);var STATEMENT_LENGTH=3;if(statement.length":return item[property]>value;case">=":return item[property]>=value;case"!=":return item[property]!==value;case"in":return item[property]in value;case"not in":return!(item[property]in value);case"contains":if(!(item[property]instanceof Array))throw"contains statement has to be applied on array";return item[property].indexOf(value)>-1;default:throw"Incorrect statement comparator: "+comparator}}function checkOrGroup(item,orGroup){var j;for(j in orGroup)if(checkStatement(item,orGroup[j]))return!0;return!1}function checkAndGroup(item,andGroup){var i;for(i in andGroup)if(!checkOrGroup(item,andGroup[i]))return!1;return!0}return{applyFilters:applyFilters}}angular.module("dynamicLayout").factory("FilterService",FilterService)}(),function(){"use strict";function PositionService($window,$document,$animate,$timeout,$q){function getItemsDimensionFromDOM(){elements=$document[0].querySelectorAll(".dynamic-layout-item-parent:not(.ng-leave)"),items=[];for(var i=0;ii;++i)columns.push([]);return columns}function getColumnsHeights(cols){var i,columnsHeights=[];for(i in cols){var h;if(cols[i].length){var lastItem=cols[i][cols[i].length-1];h=lastItem.y+lastItem.height}else h=0;columnsHeights.push(h)}return columnsHeights}function getItemColumnsAndPosition(item,colHeights,colSize){if(item.columnSpan>colHeights.length)throw"Item too large";var i,indexOfMin=0,minFound=0;for(i=0;i<=colHeights.length-item.columnSpan;++i){var startingColumn=i,endingColumn=i+item.columnSpan,maxHeightInPart=Math.max.apply(Math,colHeights.slice(startingColumn,endingColumn));(0===i||minFound>maxHeightInPart)&&(minFound=maxHeightInPart,indexOfMin=i)}var itemColumns=[];for(i=indexOfMin;ivalueB)return"asc"===ascDesc?1:-1;if(valueB>valueA)return"asc"===ascDesc?-1:1}return++i,rankers.length>i?recursiveRanker(a,b):0}var i=0;return rankers&&items.sort(sorter),items}return{applyRankers:applyRankers}}angular.module("dynamicLayout").factory("RankerService",RankerService)}(),function(){"use strict";function as($parse){return function(value,context,path){return $parse(path).assign(context,value),value}}angular.module("dynamicLayout").filter("as",["$parse",as])}(),function(){"use strict";function customFilter(FilterService){return function(items,filters){return filters?FilterService.applyFilters(items,filters):items}}angular.module("dynamicLayout").filter("customFilter",["FilterService",customFilter])}(),function(){"use strict";function customRanker(RankerService){return function(items,rankers){return rankers?RankerService.applyRankers(items,rankers):items}}angular.module("dynamicLayout").filter("customRanker",["RankerService",customRanker])}(); -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | // Karma configuration 3 | // Generated on Wed Aug 13 2014 10:18:58 GMT+0200 (CEST) 4 | 5 | module.exports = function(config) { 6 | config.set({ 7 | 8 | // base path that will be used to resolve all patterns (eg. files, exclude) 9 | basePath: '', 10 | 11 | 12 | // frameworks to use 13 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 14 | frameworks: ['jasmine'], 15 | 16 | 17 | // list of files / patterns to load in the browser 18 | files: [ 19 | 'bower_components/angular/angular.min.js', 20 | 'bower_components/angular-animate/angular-animate.min.js', 21 | 'bower_components/angular-mocks/angular-mocks.js', 22 | 'src/module.js', 23 | 'src/as.filter.js', 24 | 'src/custom-filter.filter.js', 25 | 'src/custom-ranker.filter.js', 26 | 'src/dynamic-layout.directive.js', 27 | 'src/layout-on-load.directive.js', 28 | 'src/filter.service.js', 29 | 'src/ranker.service.js', 30 | 'src/position.service.js', 31 | 'tests/**/*.js' 32 | ], 33 | 34 | 35 | // list of files to exclude 36 | exclude: [ 37 | ], 38 | 39 | 40 | // preprocess matching files before serving them to the browser 41 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 42 | preprocessors: { 43 | }, 44 | 45 | 46 | // test results reporter to use 47 | // possible values: 'dots', 'progress' 48 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 49 | reporters: ['progress'], 50 | 51 | 52 | // web server port 53 | port: 9876, 54 | 55 | 56 | // enable / disable colors in the output (reporters and logs) 57 | colors: true, 58 | 59 | 60 | // level of logging 61 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 62 | logLevel: config.LOG_INFO, 63 | 64 | 65 | // enable / disable watching file and executing tests whenever any file changes 66 | autoWatch: true, 67 | 68 | 69 | // start these browsers 70 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 71 | browsers: ['Firefox'], 72 | 73 | 74 | // Continuous Integration mode 75 | // if true, Karma captures browsers, runs the tests and exits 76 | singleRun: false 77 | }); 78 | }; 79 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-dynamic-layout", 3 | "version": "0.1.11", 4 | "description": "An angularJS approach to dynamic grid", 5 | "main": "index.js", 6 | "directories": { 7 | "doc": "doc" 8 | }, 9 | "scripts": { 10 | "test": "karma" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git://github.com/tristanguigue/angular-dynamic-layout.git" 15 | }, 16 | "devDependencies": { 17 | "bower": "~1.3.12", 18 | "grunt": "~0.4.5", 19 | "grunt-contrib-jshint": "latest", 20 | "grunt-contrib-uglify": "latest", 21 | "grunt-contrib-concat": "latest", 22 | "grunt-karma": "^0.9.0", 23 | "karma": "^0.12.24", 24 | "karma-chrome-launcher": "^0.1.5", 25 | "karma-firefox-launcher": "~0.1.3", 26 | "karma-jasmine": "~0.2.2", 27 | "karma-safari-launcher": "~0.1.1" 28 | }, 29 | "author": "Tristan Guigue", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/tristanguigue/angular-dynamic-layout/issues" 33 | }, 34 | "homepage": "https://github.com/tristanguigue/angular-dynamic-layout" 35 | } 36 | -------------------------------------------------------------------------------- /src/as.filter.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | angular 5 | .module('dynamicLayout') 6 | .filter('as', ['$parse', as]); 7 | 8 | /* 9 | * This allowed the result of the filters to be assigned to the scope 10 | */ 11 | function as($parse) { 12 | 13 | return function(value, context, path) { 14 | $parse(path).assign(context, value); 15 | return value; 16 | }; 17 | } 18 | 19 | })(); 20 | -------------------------------------------------------------------------------- /src/custom-filter.filter.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | angular 5 | .module('dynamicLayout') 6 | .filter('customFilter', ['FilterService', customFilter]); 7 | 8 | /* 9 | * The filter to be applied on the ng-repeat directive 10 | */ 11 | function customFilter(FilterService) { 12 | 13 | return function(items, filters) { 14 | if (filters) { 15 | return FilterService.applyFilters(items, filters); 16 | } 17 | return items; 18 | }; 19 | } 20 | 21 | })(); 22 | -------------------------------------------------------------------------------- /src/custom-ranker.filter.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | angular 5 | .module('dynamicLayout') 6 | .filter('customRanker', ['RankerService', customRanker]); 7 | 8 | /* 9 | * The ranker to be applied on the ng-repeat directive 10 | */ 11 | function customRanker(RankerService) { 12 | 13 | return function(items, rankers) { 14 | if (rankers) { 15 | return RankerService.applyRankers(items, rankers); 16 | } 17 | return items; 18 | }; 19 | } 20 | 21 | })(); 22 | -------------------------------------------------------------------------------- /src/dynamic-layout.directive.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | angular 5 | .module('dynamicLayout') 6 | .directive('dynamicLayout', ['$timeout', '$window', '$q', '$animate', 'PositionService', dynamicLayout]); 7 | 8 | /* 9 | * The isotope directive that renders the templates based on the array of items 10 | * passed 11 | * @scope items: the list of items to be rendered 12 | * @scope rankers: the rankers to be applied on the list of items 13 | * @scope filters: the filters to be applied on the list of items 14 | * @scope defaulttemplate: (optional) the deafult template to be applied on each item if no item template is defined 15 | */ 16 | function dynamicLayout($timeout, $window, $q, $animate, PositionService) { 17 | 18 | return { 19 | restrict: 'A', 20 | scope: { 21 | items: '=', 22 | rankers: '=', 23 | filters: '=', 24 | defaulttemplate: '=?', 25 | colWidth: '=' 26 | }, 27 | template: '
', 35 | link: link 36 | }; 37 | 38 | function link(scope, element) { 39 | 40 | // Keep count of the number of templates left to load 41 | scope.templatesToLoad = 0; 42 | scope.externalScope = externalScope; 43 | 44 | // Fires when a template is requested through the ng-include directive 45 | scope.$on('$includeContentRequested', function() { 46 | scope.templatesToLoad++; 47 | }); 48 | 49 | // Fires when a template has been loaded through the ng-include 50 | // directive 51 | scope.$on('$includeContentLoaded', function() { 52 | scope.templatesToLoad--; 53 | }); 54 | 55 | /* 56 | * Triggers a layout every time the items are changed 57 | */ 58 | scope.$watch('filteredItems', function(newValue, oldValue) { 59 | // We want the filteredItems to be available to the controller 60 | // This feels hacky, there must be a better way to do this 61 | scope.$parent.filteredItems = scope.filteredItems; 62 | 63 | if (!angular.equals(newValue, oldValue)) { 64 | itemsLoaded().then(function() { 65 | layout(); 66 | }); 67 | } 68 | }, true); 69 | 70 | /* 71 | * Triggers a layout every time the window is resized 72 | */ 73 | angular.element($window).on('resize', onResize); 74 | 75 | /* 76 | * Triggers a layout whenever requested by an external source 77 | * Allows a callback to be fired after the layout animation is 78 | * completed 79 | */ 80 | scope.$on('dynamicLayout.layout', function(event, callback) { 81 | layout().then(function() { 82 | if (angular.isFunction('function')) { 83 | callback(); 84 | } 85 | }); 86 | }); 87 | 88 | /* 89 | * Triggers the initial layout once all the templates are loaded 90 | */ 91 | itemsLoaded().then(function() { 92 | layout(); 93 | }); 94 | 95 | // Cleanup 96 | scope.$on('$destroy', function() { 97 | angular.element($window).off('resize', onResize); 98 | }); 99 | 100 | function onResize() { 101 | // We need to apply the scope 102 | scope.$apply(function() { 103 | layout(); 104 | }); 105 | } 106 | 107 | /* 108 | * Use the PositionService to layout the items 109 | * @return the promise of the cards being animated 110 | */ 111 | function layout() { 112 | return PositionService.layout(element[0].offsetWidth, scope.colWidth); 113 | } 114 | 115 | /* 116 | * Check when all the items have been loaded by the ng-include 117 | * directive 118 | */ 119 | function itemsLoaded() { 120 | var def = $q.defer(); 121 | 122 | // $timeout : We need to wait for the includeContentRequested to 123 | // be called before we can assume there is no templates to be loaded 124 | $timeout(function() { 125 | if (scope.templatesToLoad === 0) { 126 | def.resolve(); 127 | } 128 | }); 129 | 130 | scope.$watch('templatesToLoad', function(newValue, oldValue) { 131 | if (newValue !== oldValue && scope.templatesToLoad === 0) { 132 | def.resolve(); 133 | } 134 | }); 135 | 136 | return def.promise; 137 | } 138 | 139 | /* 140 | * This allows the external scope, that is the scope of 141 | * dynamic-layout's container to be called from the templates 142 | * @return the given scope 143 | */ 144 | function externalScope() { 145 | return scope.$parent; 146 | } 147 | 148 | } 149 | } 150 | 151 | })(); 152 | -------------------------------------------------------------------------------- /src/filter.service.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | angular 5 | .module('dynamicLayout') 6 | .factory('FilterService', FilterService); 7 | 8 | /* 9 | * The filter service 10 | * 11 | * COMPARATORS = ['=', '<', '>', '<=', '>=', '!=', 'in', 'not in', 'contains'] 12 | * 13 | * Allows filters in Conjuctive Normal Form using the item's property or any 14 | * custom operation on the items 15 | * 16 | * For example: 17 | * var filters = [ // an AND goup compose of OR groups 18 | * [ // an OR group compose of statements 19 | * ['color', '=', 'grey'], // A statement 20 | * ['color', '=', 'black'] 21 | * ], 22 | * [ // a second OR goup composed of statements 23 | * ['atomicNumber', '<', 3] 24 | * ] 25 | * ]; 26 | * Or 27 | * var myCustomFilter = function(item){ 28 | * if(item.color != 'red') 29 | * return true; 30 | * else 31 | * return false; 32 | * }; 33 | * 34 | * filters = [ 35 | * [myCustomFilter] 36 | * ]; 37 | * 38 | */ 39 | function FilterService() { 40 | 41 | return { 42 | applyFilters: applyFilters 43 | }; 44 | 45 | /* 46 | * Check which of the items passes the filters 47 | * @param items: the items being probed 48 | * @param filters: the array of and groups use to probe the item 49 | * @return the list of items that passes the filters 50 | */ 51 | function applyFilters(items, filters) { 52 | var retItems = []; 53 | var i; 54 | for (i in items) { 55 | if (checkAndGroup(items[i], filters)) { 56 | retItems.push(items[i]); 57 | } 58 | } 59 | return retItems; 60 | } 61 | 62 | /* 63 | * Check if a single item passes the single statement criteria 64 | * @param item: the item being probed 65 | * @param statement: the criteria being use to test the item 66 | * @return true if the item passed the statement, false otherwise 67 | */ 68 | function checkStatement(item, statement) { 69 | // If the statement is a custom filter, we give the item as a parameter 70 | if (angular.isFunction(statement)) { 71 | return statement(item); 72 | } 73 | 74 | // If the statement is a regular filter, it has to be with the form 75 | // [propertyName, comparator, value] 76 | 77 | var STATEMENT_LENGTH = 3; 78 | if (statement.length < STATEMENT_LENGTH) { 79 | throw 'Incorrect statement'; 80 | } 81 | 82 | var property = statement[0]; 83 | var comparator = statement[1]; 84 | var value = statement[2]; 85 | 86 | // If the property is not found in the item then we consider the 87 | // statement to be false 88 | if (!item[property]) { 89 | return false; 90 | } 91 | 92 | switch (comparator) { 93 | case '=': 94 | return item[property] === value; 95 | case '<': 96 | return item[property] < value; 97 | case '<=': 98 | return item[property] <= value; 99 | case '>': 100 | return item[property] > value; 101 | case '>=': 102 | return item[property] >= value; 103 | case '!=': 104 | return item[property] !== value; 105 | case 'in': 106 | return item[property] in value; 107 | case 'not in': 108 | return !(item[property] in value); 109 | case 'contains': 110 | if (!(item[property] instanceof Array)) { 111 | throw 'contains statement has to be applied on array'; 112 | } 113 | return item[property].indexOf(value) > -1; 114 | default: 115 | throw 'Incorrect statement comparator: ' + comparator; 116 | } 117 | } 118 | 119 | /* 120 | * Check a sub (or) group 121 | * @param item: the item being probed 122 | * @param orGroup: the array of statement use to probe the item 123 | * @return true if the item passed at least one of the statements, 124 | * false otherwise 125 | */ 126 | function checkOrGroup(item, orGroup) { 127 | var j; 128 | for (j in orGroup) { 129 | if (checkStatement(item, orGroup[j])) { 130 | return true; 131 | } 132 | } 133 | return false; 134 | } 135 | 136 | /* 137 | * Check the main group 138 | * @param item: the item being probed 139 | * @param orGroup: the array of or groups use to probe the item 140 | * @return true if the item passed all of of the or groups, 141 | * false otherwise 142 | */ 143 | function checkAndGroup(item, andGroup) { 144 | var i; 145 | for (i in andGroup) { 146 | if (!checkOrGroup(item, andGroup[i])) { 147 | return false; 148 | } 149 | } 150 | return true; 151 | } 152 | 153 | } 154 | 155 | })(); 156 | -------------------------------------------------------------------------------- /src/layout-on-load.directive.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | angular 5 | .module('dynamicLayout') 6 | .directive('layoutOnLoad', ['$rootScope', layoutOnLoad]); 7 | 8 | /* 9 | * Directive on images to layout after each load 10 | */ 11 | function layoutOnLoad($rootScope) { 12 | 13 | return { 14 | restrict: 'A', 15 | link: function(scope, element) { 16 | element.bind('load error', function() { 17 | $timeout.cancel(timeoutId); 18 | timeoutId = $timeout(function() { 19 | $rootScope.$broadcast('dynamicLayout.layout'); 20 | }); 21 | }); 22 | } 23 | }; 24 | } 25 | 26 | })(); 27 | -------------------------------------------------------------------------------- /src/module.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | angular 5 | .module('dynamicLayout', [ 'ngAnimate' ]); 6 | 7 | })(); 8 | -------------------------------------------------------------------------------- /src/position.service.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | angular 5 | .module('dynamicLayout') 6 | .factory('PositionService', ['$window', '$document', '$animate', '$timeout', '$q', PositionService]); 7 | 8 | /* 9 | * The position service 10 | * 11 | * Find the best adjustements of the elemnts in the DOM according the their 12 | * order, height and width 13 | * 14 | * Fix their absolute position in the DOM while adding a ng-animate class for 15 | * personalized animations 16 | * 17 | */ 18 | function PositionService($window, $document, $animate, $timeout, $q) { 19 | 20 | // The list of ongoing animations 21 | var ongoingAnimations = {}; 22 | // The list of items related to the DOM elements 23 | var items = []; 24 | // The list of the DOM elements 25 | var elements = []; 26 | // The columns that contains the items 27 | var columns = []; 28 | 29 | var self = { 30 | getItemsDimensionFromDOM: getItemsDimensionFromDOM, 31 | applyToDOM: applyToDOM, 32 | layout: layout, 33 | getColumns: getColumns 34 | }; 35 | return self; 36 | 37 | /* 38 | * Get the items heights and width from the DOM 39 | * @return: the list of items with their sizes 40 | */ 41 | function getItemsDimensionFromDOM() { 42 | // not(.ng-leave) : we don't want to select elements that have been 43 | // removed but are still in the DOM 44 | elements = $document[0].querySelectorAll( 45 | '.dynamic-layout-item-parent:not(.ng-leave)' 46 | ); 47 | items = []; 48 | for (var i = 0; i < elements.length; ++i) { 49 | // Note: we need to get the children element width because that's 50 | // where the style is applied 51 | var rect = elements[i].children[0].getBoundingClientRect(); 52 | var width; 53 | var height; 54 | if (rect.width) { 55 | width = rect.width; 56 | height = rect.height; 57 | } else { 58 | width = rect.right - rect.left; 59 | height = rect.top - rect.bottom; 60 | } 61 | 62 | items.push({ 63 | height: height + 64 | parseFloat($window.getComputedStyle(elements[i]).marginTop), 65 | width: width + 66 | parseFloat( 67 | $window.getComputedStyle(elements[i].children[0]).marginLeft 68 | ) 69 | }); 70 | } 71 | return items; 72 | } 73 | 74 | /* 75 | * Apply positions to the DOM with an animation 76 | * @return: the promise of the position animations being completed 77 | */ 78 | function applyToDOM() { 79 | 80 | var ret = $q.defer(); 81 | 82 | /* 83 | * Launch an animation on a specific element 84 | * Once the animation is complete remove it from the ongoing animation 85 | * @param element: the element being moved 86 | * @param i: the index of the current animation 87 | * @return: the promise of the animation being completed 88 | */ 89 | function launchAnimation(element, i) { 90 | var animationPromise = $animate.addClass(element, 91 | 'move-items-animation', 92 | { 93 | from: { 94 | position: 'absolute' 95 | }, 96 | to: { 97 | left: items[i].x + 'px', 98 | top: items[i].y + 'px' 99 | } 100 | } 101 | ); 102 | 103 | animationPromise.then(function() { 104 | // We remove the class so that the animation can be ran again 105 | element.classList.remove('move-items-animation'); 106 | delete ongoingAnimations[i]; 107 | }); 108 | 109 | return animationPromise; 110 | } 111 | 112 | /* 113 | * Launch the animations on all the elements 114 | * @return: the promise of the animations being completed 115 | */ 116 | function launchAnimations() { 117 | var i; 118 | for (i = 0; i < items.length; ++i) { 119 | // We need to pass the specific element we're dealing with 120 | // because at the next iteration elements[i] might point to 121 | // something else 122 | ongoingAnimations[i] = launchAnimation(elements[i], i); 123 | } 124 | $q.all(ongoingAnimations).then(function() { 125 | ret.resolve(); 126 | }); 127 | } 128 | 129 | // We need to cancel all ongoing animations before we start the new 130 | // ones 131 | if (Object.keys(ongoingAnimations).length) { 132 | for (var j in ongoingAnimations) { 133 | $animate.cancel(ongoingAnimations[j]); 134 | delete ongoingAnimations[j]; 135 | } 136 | } 137 | 138 | // For some reason we need to launch the new animations at the next 139 | // digest 140 | $timeout(function() { 141 | launchAnimations(ret); 142 | }); 143 | 144 | return ret.promise; 145 | } 146 | 147 | /* 148 | * Apply the position service on the elements in the DOM 149 | * @param containerWidth: the width of the dynamic-layout container 150 | * @return: the promise of the position animations being completed 151 | */ 152 | function layout(containerWidth, colWidth) { 153 | // We first gather the items dimension based on the DOM elements 154 | items = self.getItemsDimensionFromDOM(); 155 | 156 | // Then we get the column size base the elements minimum width 157 | var colWidth = colWidth || getColWidth(); 158 | var nbColumns = Math.floor(containerWidth / colWidth); 159 | // We create empty columns to be filled with the items 160 | initColumns(nbColumns); 161 | 162 | // We determine what is the column size of each of the items based on 163 | // their width and the column size 164 | setItemsColumnSpan(colWidth); 165 | 166 | // We set what should be their absolute position in the DOM 167 | setItemsPosition(columns, colWidth); 168 | 169 | // We apply those positions to the DOM with an animation 170 | return self.applyToDOM(); 171 | } 172 | 173 | // Make the columns public 174 | function getColumns() { 175 | return columns; 176 | } 177 | 178 | /* 179 | * Intialize the columns 180 | * @param nb: the number of columns to be initialized 181 | * @return: the empty columns 182 | */ 183 | function initColumns(nb) { 184 | columns = []; 185 | var i; 186 | for (i = 0; i < nb; ++i) { 187 | columns.push([]); 188 | } 189 | return columns; 190 | } 191 | 192 | /* 193 | * Get the columns heights 194 | * @param columns: the columns with the items they contain 195 | * @return: an array of columns heights 196 | */ 197 | function getColumnsHeights(cols) { 198 | var columnsHeights = []; 199 | var i; 200 | for (i in cols) { 201 | var h; 202 | if (cols[i].length) { 203 | var lastItem = cols[i][cols[i].length - 1]; 204 | h = lastItem.y + lastItem.height; 205 | } else { 206 | h = 0; 207 | } 208 | columnsHeights.push(h); 209 | } 210 | return columnsHeights; 211 | } 212 | 213 | /* 214 | * Find the item absolute position and what columns it belongs too 215 | * @param item: the item to place 216 | * @param colHeights: the current heigh of the column when all items prior to this 217 | * one were places 218 | * @param colWidth: the column width 219 | * @return the item's columms and coordinates 220 | */ 221 | function getItemColumnsAndPosition(item, colHeights, colWidth) { 222 | if (item.columnSpan > colHeights.length) { 223 | throw 'Item too large'; 224 | } 225 | 226 | var indexOfMin = 0; 227 | var minFound = 0; 228 | var i; 229 | 230 | // We look at what set of columns have the minimum height 231 | for (i = 0; i <= colHeights.length - item.columnSpan; ++i) { 232 | var startingColumn = i; 233 | var endingColumn = i + item.columnSpan; 234 | var maxHeightInPart = Math.max.apply( 235 | Math, colHeights.slice(startingColumn, endingColumn) 236 | ); 237 | 238 | if (i === 0 || maxHeightInPart < minFound) { 239 | minFound = maxHeightInPart; 240 | indexOfMin = i; 241 | } 242 | } 243 | 244 | var itemColumns = []; 245 | for (i = indexOfMin; i < indexOfMin + item.columnSpan; ++i) { 246 | itemColumns.push(i); 247 | } 248 | 249 | var position = { 250 | x: itemColumns[0] * colWidth, 251 | y: minFound 252 | }; 253 | 254 | return { 255 | columns: itemColumns, 256 | position: position 257 | }; 258 | } 259 | 260 | /* 261 | * Set the items' absolute position 262 | * @param columns: the empty columns 263 | * @param colWidth: the column width 264 | */ 265 | function setItemsPosition(cols, colWidth) { 266 | var i; 267 | var j; 268 | for (i = 0; i < items.length; ++i) { 269 | var columnsHeights = getColumnsHeights(cols); 270 | 271 | var itemColumnsAndPosition = getItemColumnsAndPosition(items[i], 272 | columnsHeights, 273 | colWidth); 274 | 275 | // We place the item in the found columns 276 | for (j in itemColumnsAndPosition.columns) { 277 | columns[itemColumnsAndPosition.columns[j]].push(items[i]); 278 | } 279 | 280 | items[i].x = itemColumnsAndPosition.position.x; 281 | items[i].y = itemColumnsAndPosition.position.y; 282 | } 283 | } 284 | 285 | /* 286 | * Get the column size based on the minimum width of the items 287 | * @return: column size 288 | */ 289 | function getColWidth() { 290 | var i; 291 | var colWidth; 292 | for (i = 0; i < items.length; ++i) { 293 | if (!colWidth || items[i].width < colWidth) { 294 | colWidth = items[i].width; 295 | } 296 | } 297 | return colWidth; 298 | } 299 | 300 | /* 301 | * Set the column span for each of the items based on their width and the 302 | * column size 303 | * @param: column size 304 | */ 305 | function setItemsColumnSpan(colWidth) { 306 | var i; 307 | for (i = 0; i < items.length; ++i) { 308 | items[i].columnSpan = Math.ceil(items[i].width / colWidth); 309 | } 310 | } 311 | 312 | } 313 | 314 | })(); 315 | -------------------------------------------------------------------------------- /src/ranker.service.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | angular 5 | .module('dynamicLayout') 6 | .factory('RankerService', RankerService); 7 | 8 | /* 9 | * The rankers service 10 | * 11 | * Allows a list of rankers to sort the items. 12 | * If two items are the same regarding the first ranker, the second one is used 13 | * to part them, etc. 14 | * 15 | * Rankers can be either a property name or a custom operation on the item. 16 | * They all need to specify the order chosen (asc' or 'desc') 17 | * 18 | * var rankers = [ 19 | * ['color', 'asc'], 20 | * ['atomicNumber', 'desc'] 21 | * ]; 22 | * Or 23 | * var rankers = [ 24 | * [myCustomGetter, 'asc'] 25 | * ]; 26 | * 27 | */ 28 | function RankerService() { 29 | 30 | return { 31 | applyRankers: applyRankers 32 | }; 33 | 34 | /* 35 | * Order the items with the given rankers 36 | * @param items: the items being ranked 37 | * @param rankers: the array of rankers used to rank the items 38 | * @return the ordered list of items 39 | */ 40 | function applyRankers(items, rankers) { 41 | // The ranker counter 42 | var i = 0; 43 | 44 | if (rankers) { 45 | items.sort(sorter); 46 | } 47 | 48 | /* 49 | * The custom sorting function using the built comparison function 50 | * @param a, b: the items to be compared 51 | * @return -1, 0 or 1 52 | */ 53 | function sorter(a, b) { 54 | i = 0; 55 | return recursiveRanker(a, b); 56 | } 57 | 58 | /* 59 | * Compare recursively two items 60 | * It first compare the items with the first ranker, if no conclusion 61 | * can be drawn it uses the second ranker and so on until it finds a 62 | * winner or there are no more rankers 63 | * @param a, b: the items to be compared 64 | * @return -1, 0 or 1 65 | */ 66 | function recursiveRanker(a, b) { 67 | var ranker = rankers[i][0]; 68 | var ascDesc = rankers[i][1]; 69 | var valueA; 70 | var valueB; 71 | // If it is a custom ranker, give the item as input and gather the 72 | // ouput 73 | if (angular.isFunction(ranker)) { 74 | valueA = ranker(a); 75 | valueB = ranker(b); 76 | } else { 77 | // Otherwise use the item's properties 78 | if (!(ranker in a) && !(ranker in b)) { 79 | valueA = 0; 80 | valueB = 0; 81 | } else if (!(ranker in a)) { 82 | return ascDesc === 'asc' ? -1 : 1; 83 | } else if (!(ranker in b)) { 84 | return ascDesc === 'asc' ? 1 : -1; 85 | } 86 | valueA = a[ranker]; 87 | valueB = b[ranker]; 88 | } 89 | 90 | if (typeof valueA === typeof valueB) { 91 | 92 | if (angular.isString(valueA)) { 93 | var comp = valueA.localeCompare(valueB); 94 | if (comp === 1) { 95 | return ascDesc === 'asc' ? 1 : -1; 96 | } else if (comp === -1) { 97 | return ascDesc === 'asc' ? -1 : 1; 98 | } 99 | } else { 100 | if (valueA > valueB) { 101 | return ascDesc === 'asc' ? 1 : -1; 102 | } else if (valueA < valueB) { 103 | return ascDesc === 'asc' ? -1 : 1; 104 | } 105 | } 106 | } 107 | 108 | ++i; 109 | 110 | if (rankers.length > i) { 111 | return recursiveRanker(a, b); 112 | } 113 | 114 | return 0; 115 | } 116 | 117 | return items; 118 | } 119 | 120 | } 121 | 122 | })(); 123 | -------------------------------------------------------------------------------- /tests/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "jasmine": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/filter.spec.js: -------------------------------------------------------------------------------- 1 | /* globals inject */ 2 | (function() { 3 | 'use strict'; 4 | 5 | describe('FilterService', function() { 6 | 7 | beforeEach(module('dynamicLayout')); 8 | 9 | it('check that apply function exists', inject(function(FilterService) { 10 | expect( FilterService.applyFilters ).toBeDefined(); 11 | })); 12 | 13 | it('check that filter service work properly', 14 | inject(function(FilterService) { 15 | 16 | var mockedItems = [ 17 | { 18 | id: 1, 19 | color: 'red', 20 | atomicNumber: 45.65 21 | }, 22 | { 23 | id: 2, 24 | color: 'green', 25 | atomicNumber: 4.2 26 | }, 27 | { 28 | id: 3, 29 | color: 'black', 30 | atomicNumber: 4 31 | }, 32 | { 33 | id: 4, 34 | color: 'grey', 35 | atomicNumber: 60 36 | }, 37 | { 38 | id: 5, 39 | color: 'grey', 40 | atomicNumber: 1.8 41 | } 42 | ]; 43 | 44 | var filters = [ // an AND goup compose of OR groups 45 | [ // an OR group compose of statements 46 | ['color', '=', 'grey'], // A statement 47 | ['color', '=', 'black'] 48 | ], 49 | [ // a second OR goup composed of statements 50 | ['atomicNumber', '<', 3] 51 | ] 52 | ]; 53 | 54 | var itemsRes = FilterService.applyFilters(mockedItems, filters); 55 | 56 | expect(itemsRes.length).toEqual(1); 57 | expect(itemsRes[0].id ).toEqual(5); 58 | 59 | var myCustomFilter = function(item) { 60 | if (item.color !== 'red') { 61 | return true; 62 | } 63 | return false; 64 | }; 65 | 66 | filters = [ 67 | [myCustomFilter] 68 | ]; 69 | 70 | itemsRes = FilterService.applyFilters(mockedItems, filters); 71 | expect(itemsRes.length).toEqual(4); 72 | }) 73 | ); 74 | 75 | it('check that the filter service reject invalid comparators', 76 | inject(function(FilterService) { 77 | 78 | var mockedItems = [ 79 | { 80 | id: 1, 81 | color: 'red', 82 | atomicNumber: 45.65 83 | } 84 | ]; 85 | 86 | var filters = [[ 87 | ['atomicNumber', 'invalid'] 88 | ]]; 89 | 90 | expect(function() { 91 | FilterService.applyFilters(mockedItems, filters); 92 | }).toThrow('Incorrect statement'); 93 | 94 | filters = [[ 95 | ['atomicNumber', 'invalid', 3] 96 | ]]; 97 | 98 | expect(function() { 99 | FilterService.applyFilters(mockedItems, filters); 100 | }).toThrow('Incorrect statement comparator: invalid'); 101 | 102 | }) 103 | ); 104 | 105 | it('check that the filter service reject invalid property for\ 106 | "contains" comparator', 107 | inject(function(FilterService) { 108 | var mockedItems = [ 109 | { 110 | id: 1, 111 | color: 'red', 112 | atomicNumber: 45.65 113 | } 114 | ]; 115 | 116 | var filters = [[ 117 | ['atomicNumber', 'contains', 45] 118 | ]]; 119 | 120 | expect(function() { 121 | FilterService.applyFilters(mockedItems, filters); 122 | }).toThrow('contains statement has to be applied on array'); 123 | }) 124 | ); 125 | 126 | }); 127 | 128 | })(); 129 | -------------------------------------------------------------------------------- /tests/position.spec.js: -------------------------------------------------------------------------------- 1 | /* globals inject */ 2 | (function() { 3 | 'use strict'; 4 | 5 | describe('PositionService', function() { 6 | 7 | beforeEach(module('dynamicLayout')); 8 | 9 | it('check that apply function exists', inject(function(PositionService) { 10 | expect(PositionService.layout).toBeDefined(); 11 | })); 12 | 13 | it('check that positions work properly', 14 | inject(function($q, PositionService) { 15 | 16 | var items = [ 17 | { 18 | id: 1, 19 | color: 'red', 20 | atomicNumber: 45.65, 21 | height: 10, 22 | width: 100 23 | }, 24 | { 25 | id: 2, 26 | color: 'green', 27 | atomicNumber: 4.2, 28 | height: 20, 29 | width: 100 30 | }, 31 | { 32 | id: 3, 33 | color: 'black', 34 | atomicNumber: 4, 35 | height: 150, 36 | width: 100 37 | }, 38 | { 39 | id: 4, 40 | color: 'grey', 41 | atomicNumber: 60, 42 | height: 60, 43 | width: 200 44 | }, 45 | { 46 | id: 5, 47 | color: 'grey', 48 | atomicNumber: 1.8, 49 | height: 30, 50 | width: 100 51 | } 52 | ]; 53 | 54 | // Disable DOM manipulation 55 | spyOn(PositionService, 'getItemsDimensionFromDOM') 56 | .and.returnValue(items); 57 | spyOn(PositionService, 'applyToDOM') 58 | .and.returnValue($q.defer().promise); 59 | 60 | // Test that items were properly set up in the grid 61 | // Input: list of items with their dimensions (width, height) 62 | // Output: x,y of each item 63 | 64 | var promise = PositionService.layout(300); 65 | expect(promise.then).toBeDefined(); 66 | 67 | var columns = PositionService.getColumns(); 68 | 69 | expect(columns[0].length).toEqual(3); 70 | expect(columns[1].length).toEqual(2); 71 | expect(columns[2].length).toEqual(1); 72 | 73 | expect(columns[0][0].id).toEqual(1); 74 | expect(columns[0][1].id).toEqual(4); 75 | expect(columns[0][2].id).toEqual(5); 76 | 77 | expect(columns[1][0].id).toEqual(2); 78 | expect(columns[1][1].id).toEqual(4); 79 | 80 | expect(columns[2][0].id).toEqual(3); 81 | 82 | })); 83 | 84 | it('check that item too large is detected and throws errors', 85 | inject(function(PositionService) { 86 | var items = [ 87 | { 88 | id: 1, 89 | color: 'red', 90 | atomicNumber: 45.65, 91 | height: 10, 92 | width: 600 93 | } 94 | ]; 95 | 96 | spyOn(PositionService, 'getItemsDimensionFromDOM') 97 | .and.returnValue(items); 98 | spyOn(PositionService, 'applyToDOM'); 99 | 100 | expect(function() { 101 | PositionService.layout(300); 102 | }).toThrow('Item too large'); 103 | 104 | }) 105 | ); 106 | 107 | }); 108 | 109 | })(); 110 | -------------------------------------------------------------------------------- /tests/ranker.spec.js: -------------------------------------------------------------------------------- 1 | /* globals inject */ 2 | (function() { 3 | 'use strict'; 4 | 5 | describe('RankerService', function() { 6 | 7 | beforeEach(module('dynamicLayout')); 8 | 9 | it('check that apply function exists', inject(function(RankerService) { 10 | expect(RankerService.applyRankers).toBeDefined(); 11 | })); 12 | 13 | it('check that rankers work properly', 14 | inject(function(RankerService) { 15 | 16 | var items = [ 17 | { 18 | id: 1, 19 | color: 'red', 20 | atomicNumber: 45.65 21 | }, 22 | { 23 | id: 2, 24 | color: 'green', 25 | atomicNumber: 4.2 26 | }, 27 | { 28 | id: 3, 29 | color: 'black', 30 | atomicNumber: 4 31 | }, 32 | { 33 | id: 4, 34 | color: 'grey', 35 | atomicNumber: 60 36 | }, 37 | { 38 | id: 5, 39 | color: 'grey', 40 | atomicNumber: 1.8 41 | } 42 | ]; 43 | 44 | var rankers = [ 45 | ['color', 'asc'], 46 | ['atomicNumber', 'desc'] 47 | ]; 48 | 49 | var itemsRes = RankerService.applyRankers(angular.copy(items), rankers); 50 | 51 | expect(itemsRes[0].id).toEqual(3); 52 | expect(itemsRes[1].id).toEqual(2); 53 | expect(itemsRes[2].id).toEqual(4); 54 | expect(itemsRes[3].id).toEqual(5); 55 | expect(itemsRes[4].id).toEqual(1); 56 | 57 | var myCustomGetter = function(item) { 58 | if (item.atomicNumber > 5) { 59 | return 1; 60 | } 61 | return 0; 62 | }; 63 | 64 | rankers = [ 65 | [myCustomGetter, 'asc'] 66 | ]; 67 | itemsRes = RankerService.applyRankers(angular.copy(items), rankers); 68 | expect(itemsRes[0].id).toEqual(2); 69 | expect(itemsRes[1].id).toEqual(3); 70 | expect(itemsRes[2].id).toEqual(5); 71 | expect(itemsRes[3].id).toEqual(1); 72 | expect(itemsRes[4].id).toEqual(4); 73 | 74 | })); 75 | 76 | }); 77 | 78 | })(); 79 | --------------------------------------------------------------------------------