muuri
Version:
Responsive, sortable, filterable and draggable grid layouts.
492 lines (440 loc) • 13.8 kB
JavaScript
/**
* Muuri Packer
* Copyright (c) 2016-present, Niklas Rämö <inramo@gmail.com>
* Released under the MIT license
* https://github.com/haltu/muuri/blob/master/src/Packer/LICENSE.md
*/
/**
* This is the default layout algorithm for Muuri. Based on MAXRECTS approach
* as described by Jukka Jylänki in his survey: "A Thousand Ways to Pack the
* Bin - A Practical Approach to Two-Dimensional Rectangle Bin Packing.".
*
* @class
*/
function Packer() {
this._slots = [];
this._slotSizes = [];
this._freeSlots = [];
this._newSlots = [];
this._rectItem = {};
this._rectStore = [];
this._rectId = 0;
// The layout return data, which will be populated in getLayout.
this._layout = {
slots: null,
setWidth: false,
setHeight: false,
width: false,
height: false
};
// Bind sort handlers.
this._sortRectsLeftTop = this._sortRectsLeftTop.bind(this);
this._sortRectsTopLeft = this._sortRectsTopLeft.bind(this);
}
/**
* @public
* @memberof Packer.prototype
* @param {Item[]} items
* @param {Number} width
* @param {Number} height
* @param {Number[]} [slots]
* @param {Object} [options]
* @param {Boolean} [options.fillGaps=false]
* @param {Boolean} [options.horizontal=false]
* @param {Boolean} [options.alignRight=false]
* @param {Boolean} [options.alignBottom=false]
* @returns {LayoutData}
*/
Packer.prototype.getLayout = function(items, width, height, slots, options) {
var layout = this._layout;
var fillGaps = !!(options && options.fillGaps);
var isHorizontal = !!(options && options.horizontal);
var alignRight = !!(options && options.alignRight);
var alignBottom = !!(options && options.alignBottom);
var rounding = !!(options && options.rounding);
var slotSizes = this._slotSizes;
var i;
// Reset layout data.
layout.slots = slots ? slots : this._slots;
layout.width = isHorizontal ? 0 : rounding ? Math.round(width) : width;
layout.height = !isHorizontal ? 0 : rounding ? Math.round(height) : height;
layout.setWidth = isHorizontal;
layout.setHeight = !isHorizontal;
// Make sure slots and slot size arrays are reset.
layout.slots.length = 0;
slotSizes.length = 0;
// No need to go further if items do not exist.
if (!items.length) return layout;
// Find slots for items.
for (i = 0; i < items.length; i++) {
this._addSlot(items[i], isHorizontal, fillGaps, rounding, alignRight || alignBottom);
}
// If the alignment is set to right we need to adjust the results.
if (alignRight) {
for (i = 0; i < layout.slots.length; i = i + 2) {
layout.slots[i] = layout.width - (layout.slots[i] + slotSizes[i]);
}
}
// If the alignment is set to bottom we need to adjust the results.
if (alignBottom) {
for (i = 1; i < layout.slots.length; i = i + 2) {
layout.slots[i] = layout.height - (layout.slots[i] + slotSizes[i]);
}
}
// Reset slots arrays and rect id.
slotSizes.length = 0;
this._freeSlots.length = 0;
this._newSlots.length = 0;
this._rectId = 0;
return layout;
};
/**
* Calculate position for the layout item. Returns the left and top position
* of the item in pixels.
*
* @private
* @memberof Packer.prototype
* @param {Item} item
* @param {Boolean} isHorizontal
* @param {Boolean} fillGaps
* @param {Boolean} rounding
* @returns {Array}
*/
Packer.prototype._addSlot = (function() {
var leeway = 0.001;
var itemSlot = {};
return function(item, isHorizontal, fillGaps, rounding, trackSize) {
var layout = this._layout;
var freeSlots = this._freeSlots;
var newSlots = this._newSlots;
var rect;
var rectId;
var potentialSlots;
var ignoreCurrentSlots;
var i;
var ii;
// Reset new slots.
newSlots.length = 0;
// Set item slot initial data.
itemSlot.left = null;
itemSlot.top = null;
itemSlot.width = item._width + item._marginLeft + item._marginRight;
itemSlot.height = item._height + item._marginTop + item._marginBottom;
// Round item slot width and height if needed.
if (rounding) {
itemSlot.width = Math.round(itemSlot.width);
itemSlot.height = Math.round(itemSlot.height);
}
// Try to find a slot for the item.
for (i = 0; i < freeSlots.length; i++) {
rectId = freeSlots[i];
if (!rectId) continue;
rect = this._getRect(rectId);
if (itemSlot.width <= rect.width + leeway && itemSlot.height <= rect.height + leeway) {
itemSlot.left = rect.left;
itemSlot.top = rect.top;
break;
}
}
// If no slot was found for the item.
if (itemSlot.left === null) {
// Position the item in to the bottom left (vertical mode) or top right
// (horizontal mode) of the grid.
itemSlot.left = !isHorizontal ? 0 : layout.width;
itemSlot.top = !isHorizontal ? layout.height : 0;
// If gaps don't needs filling do not add any current slots to the new
// slots array.
if (!fillGaps) {
ignoreCurrentSlots = true;
}
}
// In vertical mode, if the item's bottom overlaps the grid's bottom.
if (!isHorizontal && itemSlot.top + itemSlot.height > layout.height) {
// If item is not aligned to the left edge, create a new slot.
if (itemSlot.left > 0) {
newSlots.push(this._addRect(0, layout.height, itemSlot.left, Infinity));
}
// If item is not aligned to the right edge, create a new slot.
if (itemSlot.left + itemSlot.width < layout.width) {
newSlots.push(
this._addRect(
itemSlot.left + itemSlot.width,
layout.height,
layout.width - itemSlot.left - itemSlot.width,
Infinity
)
);
}
// Update grid height.
layout.height = itemSlot.top + itemSlot.height;
}
// In horizontal mode, if the item's right overlaps the grid's right edge.
if (isHorizontal && itemSlot.left + itemSlot.width > layout.width) {
// If item is not aligned to the top, create a new slot.
if (itemSlot.top > 0) {
newSlots.push(this._addRect(layout.width, 0, Infinity, itemSlot.top));
}
// If item is not aligned to the bottom, create a new slot.
if (itemSlot.top + itemSlot.height < layout.height) {
newSlots.push(
this._addRect(
layout.width,
itemSlot.top + itemSlot.height,
Infinity,
layout.height - itemSlot.top - itemSlot.height
)
);
}
// Update grid width.
layout.width = itemSlot.left + itemSlot.width;
}
// Clean up the current slots making sure there are no old slots that
// overlap with the item. If an old slot overlaps with the item, split it
// into smaller slots if necessary.
for (i = fillGaps ? 0 : ignoreCurrentSlots ? freeSlots.length : i; i < freeSlots.length; i++) {
rectId = freeSlots[i];
if (!rectId) continue;
rect = this._getRect(rectId);
potentialSlots = this._splitRect(rect, itemSlot);
for (ii = 0; ii < potentialSlots.length; ii++) {
rectId = potentialSlots[ii];
rect = this._getRect(rectId);
// Let's make sure here that we have a big enough slot
// (width/height > 0.49px) and also let's make sure that the slot is
// within the boundaries of the grid.
if (
rect.width > 0.49 &&
rect.height > 0.49 &&
((!isHorizontal && rect.top < layout.height) ||
(isHorizontal && rect.left < layout.width))
) {
newSlots.push(rectId);
}
}
}
// Sanitize new slots.
if (newSlots.length) {
this._purgeRects(newSlots).sort(
isHorizontal ? this._sortRectsLeftTop : this._sortRectsTopLeft
);
}
// Update layout width/height.
if (isHorizontal) {
layout.width = Math.max(layout.width, itemSlot.left + itemSlot.width);
} else {
layout.height = Math.max(layout.height, itemSlot.top + itemSlot.height);
}
// Add item slot data to layout slots (and store the slot size for later
// usage too if necessary).
layout.slots.push(itemSlot.left, itemSlot.top);
if (trackSize) this._slotSizes.push(itemSlot.width, itemSlot.height);
// Free/new slots switcheroo!
this._freeSlots = newSlots;
this._newSlots = freeSlots;
};
})();
/**
* Add a new rectangle to the rectangle store. Returns the id of the new
* rectangle.
*
* @private
* @memberof Packer.prototype
* @param {Number} left
* @param {Number} top
* @param {Number} width
* @param {Number} height
* @returns {RectId}
*/
Packer.prototype._addRect = function(left, top, width, height) {
var rectId = ++this._rectId;
var rectStore = this._rectStore;
rectStore[rectId] = left || 0;
rectStore[++this._rectId] = top || 0;
rectStore[++this._rectId] = width || 0;
rectStore[++this._rectId] = height || 0;
return rectId;
};
/**
* Get rectangle data from the rectangle store by id. Optionally you can
* provide a target object where the rectangle data will be written in. By
* default an internal object is reused as a target object.
*
* @private
* @memberof Packer.prototype
* @param {RectId} id
* @param {Object} [target]
* @returns {Object}
*/
Packer.prototype._getRect = function(id, target) {
var rectItem = target ? target : this._rectItem;
var rectStore = this._rectStore;
rectItem.left = rectStore[id] || 0;
rectItem.top = rectStore[++id] || 0;
rectItem.width = rectStore[++id] || 0;
rectItem.height = rectStore[++id] || 0;
return rectItem;
};
/**
* Punch a hole into a rectangle and split the remaining area into smaller
* rectangles (4 at max).
*
* @private
* @memberof Packer.prototype
* @param {Rectangle} rect
* @param {Rectangle} hole
* @returns {RectId[]}
*/
Packer.prototype._splitRect = (function() {
var results = [];
return function(rect, hole) {
// Reset old results.
results.length = 0;
// If the rect does not overlap with the hole add rect to the return data
// as is.
if (!this._doRectsOverlap(rect, hole)) {
results.push(this._addRect(rect.left, rect.top, rect.width, rect.height));
return results;
}
// Left split.
if (rect.left < hole.left) {
results.push(this._addRect(rect.left, rect.top, hole.left - rect.left, rect.height));
}
// Right split.
if (rect.left + rect.width > hole.left + hole.width) {
results.push(
this._addRect(
hole.left + hole.width,
rect.top,
rect.left + rect.width - (hole.left + hole.width),
rect.height
)
);
}
// Top split.
if (rect.top < hole.top) {
results.push(this._addRect(rect.left, rect.top, rect.width, hole.top - rect.top));
}
// Bottom split.
if (rect.top + rect.height > hole.top + hole.height) {
results.push(
this._addRect(
rect.left,
hole.top + hole.height,
rect.width,
rect.top + rect.height - (hole.top + hole.height)
)
);
}
return results;
};
})();
/**
* Check if two rectangles overlap.
*
* @private
* @memberof Packer.prototype
* @param {Rectangle} a
* @param {Rectangle} b
* @returns {Boolean}
*/
Packer.prototype._doRectsOverlap = function(a, b) {
return !(
a.left + a.width <= b.left ||
b.left + b.width <= a.left ||
a.top + a.height <= b.top ||
b.top + b.height <= a.top
);
};
/**
* Check if a rectangle is fully within another rectangle.
*
* @private
* @memberof Packer.prototype
* @param {Rectangle} a
* @param {Rectangle} b
* @returns {Boolean}
*/
Packer.prototype._isRectWithinRect = function(a, b) {
return (
a.left >= b.left &&
a.top >= b.top &&
a.left + a.width <= b.left + b.width &&
a.top + a.height <= b.top + b.height
);
};
/**
* Loops through an array of rectangle ids and resets all that are fully
* within another rectangle in the array. Resetting in this case means that
* the rectangle id value is replaced with zero.
*
* @private
* @memberof Packer.prototype
* @param {RectId[]} rectIds
* @returns {RectId[]}
*/
Packer.prototype._purgeRects = (function() {
var rectA = {};
var rectB = {};
return function(rectIds) {
var i = rectIds.length;
var ii;
while (i--) {
ii = rectIds.length;
if (!rectIds[i]) continue;
this._getRect(rectIds[i], rectA);
while (ii--) {
if (!rectIds[ii] || i === ii) continue;
if (this._isRectWithinRect(rectA, this._getRect(rectIds[ii], rectB))) {
rectIds[i] = 0;
break;
}
}
}
return rectIds;
};
})();
/**
* Sort rectangles with top-left gravity.
*
* @private
* @memberof Packer.prototype
* @param {RectId} aId
* @param {RectId} bId
* @returns {Number}
*/
Packer.prototype._sortRectsTopLeft = (function() {
var rectA = {};
var rectB = {};
return function(aId, bId) {
this._getRect(aId, rectA);
this._getRect(bId, rectB);
// prettier-ignore
return rectA.top < rectB.top ? -1 :
rectA.top > rectB.top ? 1 :
rectA.left < rectB.left ? -1 :
rectA.left > rectB.left ? 1 : 0;
};
})();
/**
* Sort rectangles with left-top gravity.
*
* @private
* @memberof Packer.prototype
* @param {RectId} aId
* @param {RectId} bId
* @returns {Number}
*/
Packer.prototype._sortRectsLeftTop = (function() {
var rectA = {};
var rectB = {};
return function(aId, bId) {
this._getRect(aId, rectA);
this._getRect(bId, rectB);
// prettier-ignore
return rectA.left < rectB.left ? -1 :
rectA.left > rectB.left ? 1 :
rectA.top < rectB.top ? -1 :
rectA.top > rectB.top ? 1 : 0;
};
})();
export default Packer;