@hydroperx/tiles
Version:
Metro tile layout
393 lines (392 loc) • 14.8 kB
JavaScript
/**
* A tile in the `BaseLayout` class.
*/
export class BaseTile {
/**
* @param x X coordinate in small tiles unit (1x1).
* @param y Y coordinate in small tiles unit (1x1).
* @param width Width in small tiles unit (1x1).
* @param height Height in small tiles unit (1x1).
*/
constructor(x, y, width, height) {
Object.defineProperty(this, "x", {
enumerable: true,
configurable: true,
writable: true,
value: x
});
Object.defineProperty(this, "y", {
enumerable: true,
configurable: true,
writable: true,
value: y
});
Object.defineProperty(this, "width", {
enumerable: true,
configurable: true,
writable: true,
value: width
});
Object.defineProperty(this, "height", {
enumerable: true,
configurable: true,
writable: true,
value: height
});
}
/**
* Checks whether two tiles intersect.
*/
intersects(other) {
return !(this.x + this.width <= other.x ||
this.x >= other.x + other.width ||
this.y + this.height <= other.y ||
this.y >= other.y + other.height);
}
/**
* Clones tile data.
*/
clone() {
return new BaseTile(this.x, this.y, this.width, this.height);
}
}
/**
* A layout mimmicking the Windows 8 or 10's live tile layout.
*
* Tiles have a minimum position of (0, 0), and the maximum
* position is either infinite, or:
*
* - If `width` is given in the constructor, maximum X = `width`.
* - If `height` is given in the constructor, maximum Y = `height`.
*/
export class BaseLayout {
/**
* Constructor.
*
* - A `width` may be specified to limit how far tiles can go horizontally.
* - A `height` may be specified to limit how far tiles can go vertically.
*/
constructor({ width, height }) {
/**
* Tile data.
*/
Object.defineProperty(this, "tiles", {
enumerable: true,
configurable: true,
writable: true,
value: new Map()
});
/**
* Maximum width.
*/
Object.defineProperty(this, "maxWidth", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/**
* Maximum height.
*/
Object.defineProperty(this, "maxHeight", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
this.maxWidth = width;
this.maxHeight = height;
}
/**
* Returns whether a specific tile exists.
*/
hasTile(id) {
return this.tiles.has(id);
}
/**
* Returns the size of the layout in small tile units (1x1).
*/
getLayoutSize() {
let maxX = 0;
let maxY = 0;
for (const tile of this.tiles.values()) {
maxX = Math.max(maxX, tile.x + tile.width);
maxY = Math.max(maxY, tile.y + tile.height);
}
return { width: maxX, height: maxY };
}
/**
* Attempts to add a tile, shifting any overlapping tiles as needed.
*
* If `x` and `y` are given as `null`, then this method always succeeds,
* as the tile will be added into the best last position.
*
* @param x X coordinate in small tiles unit (1x1), or `null`.
* @param y Y coordinate in small tiles unit (1x1), or `null`.
* @throws A TypeError if either x or y are null, but not both are null.
* @returns `true` if there was no unsolvable conflict, and `false` otherwise.
*/
addTile(id, x, y, width, height) {
const newTile = new BaseTile(x ?? 0, y ?? 0, width, height);
if (x === null || y === null) {
if ((x === null && y !== null) || (x !== null && y === null)) {
throw new TypeError("If either x or y are null, then both must be null.");
}
const best = this.findBestPosition(width, height);
newTile.x = best.x;
newTile.y = best.y;
}
const originalState = this.snapshot();
this.tiles.set(id, newTile);
if (this.resolveConflicts(id))
return true;
this.restoreSnapshot(originalState);
return false;
}
/**
* Attempts to move a tile, shifting overlapping tiles as needed.
*
* @param x X coordinate in small tiles unit (1x1).
* @param y Y coordinate in small tiles unit (1x1).
* @returns `true` if there was no unsolvable conflict, and `false` otherwise.
*/
moveTile(id, x, y) {
const tile = this.tiles.get(id);
if (!tile)
return false;
const originalState = this.snapshot();
tile.x = x;
tile.y = y;
if (this.resolveConflicts(id))
return true;
this.restoreSnapshot(originalState);
return false;
}
/**
* Attempts to resize a tile, shifting overlapping tiles as needed.
*
* @returns `true` if there was no unsolvable conflict, and `false` otherwise.
*/
resizeTile(id, width, height) {
const tile = this.tiles.get(id);
if (!tile)
return false;
const originalState = this.snapshot();
tile.width = width;
tile.height = height;
if (this.resolveConflicts(id))
return true;
this.restoreSnapshot(originalState);
return false;
}
/**
* Removes a tile, pushing any bottom-located neighbours at fitting horizontal line
* towards the removed tile.
*/
removeTile(id) {
const removed = this.tiles.get(id);
if (!removed)
return;
this.tiles.delete(id);
// Push horizontally-fitting bottom neighbours.
for (const [tid, tile] of this.tiles) {
if (tile.y > removed.y && tile.x >= removed.x && tile.x + tile.width <= removed.x + removed.width) {
tile.y = Math.max(0, tile.y - removed.height);
}
}
}
/**
* Clears everything.
*/
clear() {
this.tiles.clear();
}
// Resolve overlapping tiles of a target tile by shifting them
// somewhere else around the original position,
// and ensures the target tile is within bounds.
resolveConflicts(targetId) {
const toCheck = [targetId];
while (toCheck.length > 0) {
const id = toCheck.pop();
const tile = this.tiles.get(id);
const intersectingIds = this.getIntersectingTiles(tile, id);
if (intersectingIds.length > 0) {
const snapshotBeforeCluster = this.snapshot();
const success = this.tryShiftTileCluster(intersectingIds);
if (!success) {
this.restoreSnapshot(snapshotBeforeCluster);
return false;
}
toCheck.push(...intersectingIds);
}
const isOutOfBounds = tile.x < 0 ||
tile.y < 0 ||
(this.maxWidth !== undefined && tile.x + tile.width > this.maxWidth) ||
(this.maxHeight !== undefined && tile.y + tile.height > this.maxHeight);
if (isOutOfBounds) {
let foundPos = this.findAvailableNearbyPosition(tile, id, tile.x, tile.y);
if (!foundPos) {
foundPos = this.findAvailablePositionFor(tile, id);
}
if (!foundPos)
return false;
tile.x = foundPos.x;
tile.y = foundPos.y;
}
}
return true;
}
// Finds a best last position.
findBestPosition(width, height) {
const layoutWidth = this.maxWidth ?? Infinity;
const layoutHeight = this.maxHeight ?? Infinity;
const horizontalLayout = this.maxHeight !== undefined;
// If horizontal layout: scan by columns (x outer, y inner)
if (horizontalLayout) {
for (let x = 0; x + width <= layoutWidth; x++) {
for (let y = 0; y + height <= layoutHeight; y++) {
const testTile = new BaseTile(x, y, width, height);
const overlaps = [...this.tiles.values()].some(t => t.intersects(testTile));
if (!overlaps)
return { x, y };
}
}
}
else {
// Vertical layout: scan by rows (y outer, x inner)
for (let y = 0; y + height <= layoutHeight; y++) {
for (let x = 0; x + width <= layoutWidth; x++) {
const testTile = new BaseTile(x, y, width, height);
const overlaps = [...this.tiles.values()].some(t => t.intersects(testTile));
if (!overlaps)
return { x, y };
}
}
}
return { x: 0, y: 0 }; // fallback
}
// Method used in conflict resolution.
findAvailableNearbyPosition(tile, excludeId, originX, originY, maxRadius = 10) {
const layoutWidth = this.maxWidth ?? Infinity;
const layoutHeight = this.maxHeight ?? Infinity;
const horizontalLayout = this.maxHeight !== undefined;
// Prefer vertical moves in horizontal layout, horizontal moves in vertical layout
const directions = horizontalLayout
? [[0, 1], [0, -1], [1, 0], [-1, 0]] // vertical first
: [[1, 0], [-1, 0], [0, 1], [0, -1]]; // horizontal first
for (let radius = 0; radius <= maxRadius; radius++) {
for (const [dxBase, dyBase] of directions) {
for (let step = -radius; step <= radius; step++) {
const dx = dxBase * Math.abs(step);
const dy = dyBase * Math.abs(step);
const x = originX + dx;
const y = originY + dy;
if (x < 0 || y < 0 ||
x + tile.width > layoutWidth ||
y + tile.height > layoutHeight)
continue;
const testTile = new BaseTile(x, y, tile.width, tile.height);
const overlaps = [...this.tiles.entries()].some(([id, other]) => id !== excludeId && testTile.intersects(other));
if (!overlaps)
return { x, y };
}
}
}
return null;
}
// Method used in conflict resolution.
findAvailablePositionFor(tile, excludeId) {
const layoutWidth = this.maxWidth ?? Infinity;
const layoutHeight = this.maxHeight ?? Infinity;
for (let y = 0; y + tile.height <= layoutHeight; y++) {
for (let x = 0; x + tile.width <= layoutWidth; x++) {
const testTile = new BaseTile(x, y, tile.width, tile.height);
const overlaps = [...this.tiles.entries()].some(([id, other]) => id !== excludeId && testTile.intersects(other));
if (!overlaps) {
return { x, y };
}
}
}
return null;
}
// Intersecting tiles
getIntersectingTiles(tile, excludeId) {
const result = [];
for (const [id, other] of this.tiles.entries()) {
if (id !== excludeId && tile.intersects(other)) {
result.push(id);
}
}
return result;
}
// Try shifting a tile cluster
tryShiftTileCluster(tileIds) {
if (tileIds.length === 0)
return true;
// Compute bounding box
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const tiles = tileIds.map(id => this.tiles.get(id));
for (const tile of tiles) {
minX = Math.min(minX, tile.x);
minY = Math.min(minY, tile.y);
maxX = Math.max(maxX, tile.x + tile.width);
maxY = Math.max(maxY, tile.y + tile.height);
}
const groupWidth = maxX - minX;
const groupHeight = maxY - minY;
const layoutWidth = this.maxWidth ?? Infinity;
const layoutHeight = this.maxHeight ?? Infinity;
const horizontalLayout = this.maxHeight !== undefined;
const originalSnapshot = this.snapshot();
const maxRadius = 10;
const directions = horizontalLayout
? [[0, 1], [0, -1], [1, 0], [-1, 0]] // vertical bias
: [[1, 0], [-1, 0], [0, 1], [0, -1]]; // horizontal bias
for (let radius = 0; radius <= maxRadius; radius++) {
for (const [dxBase, dyBase] of directions) {
for (let step = -radius; step <= radius; step++) {
const dx = dxBase * Math.abs(step);
const dy = dyBase * Math.abs(step);
const offsetX = minX + dx;
const offsetY = minY + dy;
if (offsetX < 0 || offsetY < 0 ||
offsetX + groupWidth > layoutWidth ||
offsetY + groupHeight > layoutHeight)
continue;
// Try shifting all tiles
const movedPositions = {};
let fits = true;
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[i];
const id = tileIds[i];
const newX = tile.x + dx;
const newY = tile.y + dy;
const testTile = new BaseTile(newX, newY, tile.width, tile.height);
const overlapping = [...this.tiles.entries()].some(([otherId, other]) => !tileIds.includes(otherId) && testTile.intersects(other));
if (overlapping) {
fits = false;
break;
}
movedPositions[id] = testTile;
}
if (fits) {
for (const id of tileIds) {
this.tiles.set(id, movedPositions[id]);
}
return true;
}
}
}
}
this.restoreSnapshot(originalSnapshot);
return false;
}
// Returns a copy of the tile data.
snapshot() {
return new Map([...this.tiles.entries()].map(([id, tile]) => [id, tile.clone()]));
}
// Restore tile data.
restoreSnapshot(snapshot) {
this.tiles = new Map(snapshot);
}
}