UNPKG

@hydroperx/tiles

Version:
393 lines (392 loc) 14.8 kB
/** * 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); } }