diff --git a/.gitignore b/.gitignore index 99554c9..3c3629e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1 @@ -# Exclude compiled versions -tiles.js -tiles.min.js - -# Ignore node modules -node_modules \ No newline at end of file +node_modules diff --git a/src/Grid.js b/src/Grid.js index e063683..d4e489a 100644 --- a/src/Grid.js +++ b/src/Grid.js @@ -366,6 +366,9 @@ this.tilesAdded = []; this.isDirty = false; + var height = this.cellSize * this.template.numRows; + this.$el.height(height); + if (onComplete) { setTimeout(function() { onComplete(true); }, duration + 10); } diff --git a/src/Tile.js b/src/Tile.js index 7877ed9..5b888d8 100644 --- a/src/Tile.js +++ b/src/Tile.js @@ -16,6 +16,7 @@ var Tiles = {}; // cache the tile container element this.$el = $(element || document.createElement('div')); + this.$el.className = "block"; }; Tile.prototype.appendTo = function($parent, fadeIn, delay, duration) { diff --git a/tiles.js b/tiles.js new file mode 100644 index 0000000..4aa6c39 --- /dev/null +++ b/tiles.js @@ -0,0 +1,734 @@ +/*! Tiles.js | http://thinkpixellab.com/tilesjs | 2014-04-09 */ + + +// single namespace export +var Tiles = {}; + +(function($) { + + var Tile = Tiles.Tile = function(tileId, element) { + + this.id = tileId; + + // position and dimensions of tile inside the parent panel + this.top = 0; + this.left = 0; + this.width = 0; + this.height = 0; + + // cache the tile container element + this.$el = $(element || document.createElement('div')); + this.$el.className = "block"; + }; + + Tile.prototype.appendTo = function($parent, fadeIn, delay, duration) { + this.$el + .hide() + .appendTo($parent); + + if (fadeIn) { + this.$el.delay(delay).fadeIn(duration); + } + else { + this.$el.show(); + } + }; + + Tile.prototype.remove = function(animate, duration) { + if (animate) { + this.$el.fadeOut({ + complete: function() { + $(this).remove(); + } + }); + } + else { + this.$el.remove(); + } + }; + + // updates the tile layout with optional animation + Tile.prototype.resize = function(cellRect, pixelRect, animate, duration, onComplete) { + + // store the list of needed changes + var cssChanges = {}, + changed = false; + + // update position and dimensions + if (this.left !== pixelRect.x) { + cssChanges.left = pixelRect.x; + this.left = pixelRect.x; + changed = true; + } + if (this.top !== pixelRect.y) { + cssChanges.top = pixelRect.y; + this.top = pixelRect.y; + changed = true; + } + if (this.width !== pixelRect.width) { + cssChanges.width = pixelRect.width; + this.width = pixelRect.width; + changed = true; + } + if (this.height !== pixelRect.height) { + cssChanges.height = pixelRect.height; + this.height = pixelRect.height; + changed = true; + } + + // Sometimes animation fails to set the css top and left correctly + // in webkit. We'll validate upon completion of the animation and + // set the properties again if they don't match the expected values. + var tile = this, + validateChangesAndComplete = function() { + var el = tile.$el[0]; + if (tile.left !== el.offsetLeft) { + //console.log ('mismatch left:' + tile.left + ' actual:' + el.offsetLeft + ' id:' + tile.id); + tile.$el.css('left', tile.left); + } + if (tile.top !== el.offsetTop) { + //console.log ('mismatch top:' + tile.top + ' actual:' + el.offsetTop + ' id:' + tile.id); + tile.$el.css('top', tile.top); + } + + if (onComplete) { + onComplete(); + } + }; + + + // make css changes with animation when requested + if (animate && changed) { + + this.$el.animate(cssChanges, { + duration: duration, + easing: 'swing', + complete: validateChangesAndComplete + }); + } + else { + + if (changed) { + this.$el.css(cssChanges); + } + + setTimeout(validateChangesAndComplete, duration); + } + }; + +})(jQuery); + + +/* + A grid template specifies the layout of variably sized tiles. A single + cell tile should use the period character. Larger tiles may be created + using any character that is unused by a adjacent tile. Whitespace is + ignored when parsing the rows. + + Examples: + + var simpleTemplate = [ + ' A A . B ', + ' A A . B ', + ' . C C . ', + ] + + var complexTemplate = [ + ' J J . . E E ', + ' . A A . E E ', + ' B A A F F . ', + ' B . D D . H ', + ' C C D D G H ', + ' C C . . G . ', + ]; +*/ + +(function($) { + + // remove whitespace and create 2d array + var parseCells = function(rows) { + var cells = [], + numRows = rows.length, + x, y, row, rowLength, cell; + + // parse each row + for(y = 0; y < numRows; y++) { + + row = rows[y]; + cells[y] = []; + + // parse the cells in a single row + for (x = 0, rowLength = row.length; x < rowLength; x++) { + cell = row[x]; + if (cell !== ' ') { + cells[y].push(cell); + } + } + } + + // TODO: check to make sure the array isn't jagged + + return cells; + }; + + function Rectangle(x, y, width, height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + Rectangle.prototype.copy = function() { + return new Rectangle(this.x, this.y, this.width, this.height); + }; + + Tiles.Rectangle = Rectangle; + + // convert a 2d array of cell ids to a list of tile rects + var parseRects = function(cells) { + var rects = [], + numRows = cells.length, + numCols = numRows === 0 ? 0 : cells[0].length, + cell, height, width, x, y, rectX, rectY; + + // make a copy of the cells that we can modify + cells = cells.slice(); + for (y = 0; y < numRows; y++) { + cells[y] = cells[y].slice(); + } + + // iterate through every cell and find rectangles + for (y = 0; y < numRows; y++) { + for(x = 0; x < numCols; x++) { + cell = cells[y][x]; + + // skip cells that are null + if (cell == null) { + continue; + } + + width = 1; + height = 1; + + if (cell !== Tiles.Template.SINGLE_CELL) { + + // find the width by going right until cell id no longer matches + while(width + x < numCols && + cell === cells[y][x + width]) { + width++; + } + + // now find height by going down + while (height + y < numRows && + cell === cells[y + height][x]) { + height++; + } + } + + // null out all cells for the rect + for(rectY = 0; rectY < height; rectY++) { + for(rectX = 0; rectX < width; rectX++) { + cells[y + rectY][x + rectX] = null; + } + } + + // add the rect + rects.push(new Rectangle(x, y, width, height)); + } + } + + return rects; + }; + + Tiles.Template = function(rects, numCols, numRows) { + this.rects = rects; + this.numTiles = this.rects.length; + this.numRows = numRows; + this.numCols = numCols; + }; + + Tiles.Template.prototype.copy = function() { + + var copyRects = [], + len, i; + for (i = 0, len = this.rects.length; i < len; i++) { + copyRects.push(this.rects[i].copy()); + } + + return new Tiles.Template(copyRects, this.numCols, this.numRows); + }; + + // appends another template (assumes both are full rectangular grids) + Tiles.Template.prototype.append = function(other) { + + if (this.numCols !== other.numCols) { + throw 'Appended templates must have the same number of columns'; + } + + // new rects begin after the last current row + var startY = this.numRows, + i, len, rect; + + // copy rects from the other template + for (i = 0, len = other.rects.length; i < len; i++) { + rect = other.rects[i]; + this.rects.push( + new Rectangle(rect.x, startY + rect.y, rect.width, rect.height)); + } + + this.numRows += other.numRows; + this.numTiles += other.numTiles; + }; + + Tiles.Template.fromJSON = function(rows) { + // convert rows to cells and then to rects + var cells = parseCells(rows), + rects = parseRects(cells); + return new Tiles.Template( + rects, + cells.length > 0 ? cells[0].length : 0, + cells.length); + }; + + Tiles.Template.prototype.toJSON = function() { + // for now we'll assume 26 chars is enough (we don't solve graph coloring) + var LABELS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + NUM_LABELS = LABELS.length, + labelIndex = 0, + rows = [], + i, len, rect, x, y, label; + + // fill in single tiles for each cell + for (y = 0; y < this.numRows; y++) { + rows[y] = []; + for (x = 0; x < this.numCols; x++) { + rows[y][x] = Tiles.Template.SINGLE_CELL; + } + } + + // now fill in bigger tiles + for (i = 0, len = this.rects.length; i < len; i++) { + rect = this.rects[i]; + if (rect.width > 1 || rect.height > 1) { + + // mark the tile position with a label + label = LABELS[labelIndex]; + for(y = 0; y < rect.height; y++) { + for(x = 0; x < rect.width; x++) { + rows[rect.y + y][rect.x + x] = label; + } + } + + // advance the label index + labelIndex = (labelIndex + 1) % NUM_LABELS; + } + } + + // turn the rows into strings + for (y = 0; y < this.numRows; y++) { + rows[y] = rows[y].join(''); + } + + return rows; + }; + + // period used to designate a single 1x1 cell tile + Tiles.Template.SINGLE_CELL = '.'; + +})(jQuery); + + +// template provider which returns simple templates with 1x1 tiles +Tiles.UniformTemplates = { + get: function(numCols, targetTiles) { + var numRows = Math.ceil(targetTiles / numCols), + rects = [], + x, y; + + // create the rects for 1x1 tiles + for (y = 0; y < numRows; y++) { + for (x = 0; x < numCols; x++) { + rects.push(new Tiles.Rectangle(x, y, 1, 1)); + } + } + + return new Tiles.Template(rects, numCols, numRows); + } +}; +(function($) { + + var Grid = Tiles.Grid = function(element) { + + this.$el = $(element); + + // animation lasts 500 ms by default + this.animationDuration = 500; + + // min width and height of a cell in the grid + this.cellSizeMin = 150; + + // the default set of factories used when creating templates + this.templateFactory = Tiles.UniformTemplates; + + // defines the page size for prioritization of positions and tiles + this.priorityPageSize = Number.MAX_VALUE; + + // spacing between tiles + this.cellPadding = 10; + + // actual width and height of a cell in the grid + this.cellSize = 0; + + // number of tile cell columns + this.numCols = 1; + + // cache the current template + this.template = null; + + // flag that tracks whether a redraw is necessary + this.isDirty = true; + + this.tiles = []; + + // keep track of added and removed tiles so we can update tiles + // and the render the grid independently. + this.tilesAdded = []; + this.tilesRemoved = []; + }; + + Grid.prototype.getContentWidth = function() { + // by default, the entire container width is used when drawing tiles + return this.$el.width(); + }; + + // gets the number of columns during a resize + Grid.prototype.resizeColumns = function() { + var panelWidth = this.getContentWidth(); + + // ensure we have at least one column + return Math.max(1, Math.floor((panelWidth + this.cellPadding) / + (this.cellSizeMin + this.cellPadding))); + }; + + // gets the cell size during a grid resize + Grid.prototype.resizeCellSize = function() { + var panelWidth = this.getContentWidth(); + return Math.ceil((panelWidth + this.cellPadding) / this.numCols) - + this.cellPadding; + }; + + Grid.prototype.resize = function() { + + var newCols = this.resizeColumns(); + if (this.numCols !== newCols && newCols > 0) { + this.numCols = newCols; + this.isDirty = true; + } + + var newCellSize = this.resizeCellSize(); + if (this.cellSize !== newCellSize && newCellSize > 0) { + this.cellSize = newCellSize; + this.isDirty = true; + } + }; + + // refresh all tiles based on the current content + Grid.prototype.updateTiles = function(newTileIds) { + + // ensure we dont have duplicate ids + newTileIds = uniques(newTileIds); + + var numTiles = newTileIds.length, + newTiles = [], + i, tile, tileId, index; + + // retain existing tiles and queue remaining tiles for removal + for (i = this.tiles.length - 1; i >= 0; i--) { + tile = this.tiles[i]; + index = $.inArray(tile.id, newTileIds); + if (index < 0) { + this.tilesRemoved.push(tile); + //console.log('Removing tile: ' + tile.id) + } + else { + newTiles[index] = tile; + } + } + + // clear existing tiles + this.tiles = []; + + // make sure we have tiles for new additions + for (i = 0; i < numTiles; i++) { + + tile = newTiles[i]; + if (!tile) { + + tileId = newTileIds[i]; + + // see if grid has a custom tile factory + if (this.createTile) { + + tile = this.createTile(tileId); + + // skip the tile if it couldn't be created + if (!tile) { + //console.log('Tile element could not be created, id: ' + tileId); + continue; + } + + } else { + + tile = new Tiles.Tile(tileId); + } + + // add tiles to queue (will be appended to DOM during redraw) + this.tilesAdded.push(tile); + //console.log('Adding tile: ' + tile.id); + } + + this.tiles.push(tile); + } + }; + + // helper to return unique items + function uniques(items) { + var results = [], + numItems = items ? items.length : 0, + i, item; + + for (i = 0; i < numItems; i++) { + item = items[i]; + if ($.inArray(item, results) === -1) { + results.push(item); + } + } + + return results; + } + + // prepend new tiles + Grid.prototype.insertTiles = function(newTileIds) { + this.addTiles(newTileIds, true); + }; + + // append new tiles + Grid.prototype.addTiles = function(newTileIds, prepend) { + + if (!newTileIds || newTileIds.length === 0) { + return; + } + + var prevTileIds = [], + prevTileCount = this.tiles.length, + i; + + // get the existing tile ids + for (i = 0; i < prevTileCount; i++) { + prevTileIds.push(this.tiles[i].id); + } + + var tileIds = prepend ? newTileIds.concat(prevTileIds) + : prevTileIds.concat(newTileIds); + this.updateTiles(tileIds); + }; + + Grid.prototype.removeTiles = function(removeTileIds) { + + if (!removeTileIds || removeTileIds.length === 0) { + return; + } + + var updateTileIds = [], + i, len, id; + + // get the set of ids which have not been removed + for (i = 0, len = this.tiles.length; i < len; i++) { + id = this.tiles[i].id; + if ($.inArray(id, removeTileIds) === -1) { + updateTileIds.push(id); + } + } + + this.updateTiles(updateTileIds); + }; + + Grid.prototype.createTemplate = function(numCols, targetTiles) { + + // ensure that we have at least one column + numCols = Math.max(1, numCols); + + var template = this.templateFactory.get(numCols, targetTiles); + if (!template) { + + // fallback in case the default factory can't generate a good template + template = Tiles.UniformTemplates.get(numCols, targetTiles); + } + + return template; + }; + + // ensures we have a good template for the specified numbef of tiles + Grid.prototype.ensureTemplate = function(numTiles) { + + // verfiy that the current template is still valid + if (!this.template || this.template.numCols !== this.numCols) { + this.template = this.createTemplate(this.numCols, numTiles); + this.isDirty = true; + } else { + + // append another template if we don't have enough rects + var missingRects = numTiles - this.template.rects.length; + if (missingRects > 0) { + this.template.append( + this.createTemplate(this.numCols, missingRects)); + this.isDirty = true; + } + + } + }; + + // helper that returns true if a tile was in the viewport or will be given + // the new pixel rect coordinates and dimensions + function wasOrWillBeVisible(viewRect, tile, newRect) { + + var viewMaxY = viewRect.y + viewRect.height, + viewMaxX = viewRect.x + viewRect.width; + + // note: y axis is the more common exclusion, so check that first + + // was the tile visible? + if (tile) { + if (!((tile.top > viewMaxY) || (tile.top + tile.height < viewRect.y) || + (tile.left > viewMaxX) || (tile.left + tile.width < viewRect.x))) { + return true; + } + } + + if (newRect) { + // will it be visible? + if (!((newRect.y > viewMaxY) || (newRect.y + newRect.height < viewRect.y) || + (newRect.x > viewMaxX) || (newRect.x + newRect.width < viewRect.x))) { + return true; + } + } + + return false; + } + + Grid.prototype.shouldRedraw = function() { + + // see if we need to calculate the cell size + if (this.cellSize <= 0) { + this.resize(); + } + + // verify that we have a template + this.ensureTemplate(this.tiles.length); + + // only redraw when necessary + var shouldRedraw = (this.isDirty || + this.tilesAdded.length > 0 || + this.tilesRemoved.length > 0); + + return shouldRedraw; + }; + + // redraws the grid after tile collection changes + Grid.prototype.redraw = function(animate, onComplete) { + + // see if we should redraw + if (!this.shouldRedraw()) { + if (onComplete) { + onComplete(false); // tell callback that we did not redraw + } + return; + } + + var numTiles = this.tiles.length, + pageSize = this.priorityPageSize, + duration = this.animationDuration, + cellPlusPadding = this.cellSize + this.cellPadding, + tileIndex = 0, + appendDelay = 0, + viewRect = new Tiles.Rectangle( + this.$el.scrollLeft(), + this.$el.scrollTop(), + this.$el.width(), + this.$el.height()), + tile, added, pageRects, pageTiles, i, len, cellRect, pixelRect, + animateTile, priorityRects, priorityTiles; + + + // chunk tile layout by pages which are internally prioritized + for (tileIndex = 0; tileIndex < numTiles; tileIndex += pageSize) { + + // get the next page of rects and tiles + pageRects = this.template.rects.slice(tileIndex, tileIndex + pageSize); + pageTiles = this.tiles.slice(tileIndex, tileIndex + pageSize); + + // create a copy that can be ordered + priorityRects = pageRects.slice(0); + priorityTiles = pageTiles.slice(0); + + // prioritize the page of rects and tiles + if (this.prioritizePage) { + this.prioritizePage(priorityRects, priorityTiles); + } + + // place all the tiles for the current page + for (i = 0, len = priorityTiles.length; i < len; i++) { + tile = priorityTiles[i]; + added = $.inArray(tile, this.tilesAdded) >= 0; + + cellRect = priorityRects[i]; + pixelRect = new Tiles.Rectangle( + cellRect.x * cellPlusPadding, + cellRect.y * cellPlusPadding, + (cellRect.width * cellPlusPadding) - this.cellPadding, + (cellRect.height * cellPlusPadding) - this.cellPadding); + + tile.resize( + cellRect, + pixelRect, + animate && !added && wasOrWillBeVisible(viewRect, tile, pixelRect), + duration); + + if (added) { + + // decide whether to animate (fadeIn) and get the duration + animateTile = animate && wasOrWillBeVisible(viewRect, null, pixelRect); + if (animateTile && this.getAppendDelay) { + appendDelay = this.getAppendDelay( + cellRect, pageRects, priorityRects, + tile, pageTiles, priorityTiles); + } else { + appendDelay = 0; + } + + tile.appendTo(this.$el, animateTile, appendDelay, duration); + } + } + } + + // fade out all removed tiles + for (i = 0, len = this.tilesRemoved.length; i < len; i++) { + tile = this.tilesRemoved[i]; + animateTile = animate && wasOrWillBeVisible(viewRect, tile, null); + tile.remove(animateTile, duration); + } + + // clear pending queues for add / remove + this.tilesRemoved = []; + this.tilesAdded = []; + this.isDirty = false; + + var height = this.cellSize * this.template.numRows; + this.$el.height(height); + + if (onComplete) { + setTimeout(function() { onComplete(true); }, duration + 10); + } + }; + +})(jQuery); diff --git a/tiles.min.js b/tiles.min.js new file mode 100644 index 0000000..e2b294d --- /dev/null +++ b/tiles.min.js @@ -0,0 +1,2 @@ +/*! Tiles.js | http://thinkpixellab.com/tilesjs | 2014-04-09 */ +var Tiles={};(function(e){var t=Tiles.Tile=function(t,n){this.id=t,this.top=0,this.left=0,this.width=0,this.height=0,this.$el=e(n||document.createElement("div")),this.$el.className="block"};t.prototype.appendTo=function(e,t,n,r){this.$el.hide().appendTo(e),t?this.$el.delay(n).fadeIn(r):this.$el.show()},t.prototype.remove=function(t,n){t?this.$el.fadeOut({complete:function(){e(this).remove()}}):this.$el.remove()},t.prototype.resize=function(e,t,n,r,i){var s={},o=!1;this.left!==t.x&&(s.left=t.x,this.left=t.x,o=!0),this.top!==t.y&&(s.top=t.y,this.top=t.y,o=!0),this.width!==t.width&&(s.width=t.width,this.width=t.width,o=!0),this.height!==t.height&&(s.height=t.height,this.height=t.height,o=!0);var u=this,a=function(){var e=u.$el[0];u.left!==e.offsetLeft&&u.$el.css("left",u.left),u.top!==e.offsetTop&&u.$el.css("top",u.top),i&&i()};n&&o?this.$el.animate(s,{duration:r,easing:"swing",complete:a}):(o&&this.$el.css(s),setTimeout(a,r))}})(jQuery),function(e){function n(e,t,n,r){this.x=e,this.y=t,this.width=n,this.height=r}var t=function(e){var t=[],n=e.length,r,i,s,o,u;for(i=0;i0?n[0].length:0,n.length)},Tiles.Template.prototype.toJSON=function(){var e="ABCDEFGHIJKLMNOPQRSTUVWXYZ",t=e.length,n=0,r=[],i,s,o,u,a,f;for(a=0;a1||o.height>1){f=e[n];for(a=0;ar||t.top+t.heighti||t.left+t.widthr||n.y+n.heighti||n.x+n.width0&&(this.numCols=e,this.isDirty=!0);var t=this.resizeCellSize();this.cellSize!==t&&t>0&&(this.cellSize=t,this.isDirty=!0)},t.prototype.updateTiles=function(t){t=n(t);var r=t.length,i=[],s,o,u,a;for(s=this.tiles.length-1;s>=0;s--)o=this.tiles[s],a=e.inArray(o.id,t),a<0?this.tilesRemoved.push(o):i[a]=o;this.tiles=[];for(s=0;s0&&(this.template.append(this.createTemplate(this.numCols,t)),this.isDirty=!0)}},t.prototype.shouldRedraw=function(){this.cellSize<=0&&this.resize(),this.ensureTemplate(this.tiles.length);var e=this.isDirty||this.tilesAdded.length>0||this.tilesRemoved.length>0;return e},t.prototype.redraw=function(t,n){if(!this.shouldRedraw()){n&&n(!1);return}var i=this.tiles.length,s=this.priorityPageSize,o=this.animationDuration,u=this.cellSize+this.cellPadding,a=0,f=0,l=new Tiles.Rectangle(this.$el.scrollLeft(),this.$el.scrollTop(),this.$el.width(),this.$el.height()),c,h,p,d,v,m,g,y,b,w,E;for(a=0;a=0,g=w[v],y=new Tiles.Rectangle(g.x*u,g.y*u,g.width*u-this.cellPadding,g.height*u-this.cellPadding),c.resize(g,y,t&&!h&&r(l,c,y),o),h&&(b=t&&r(l,null,y),b&&this.getAppendDelay?f=this.getAppendDelay(g,p,w,c,d,E):f=0,c.appendTo(this.$el,b,f,o))}for(v=0,m=this.tilesRemoved.length;v