252 lines
8.4 KiB
TypeScript
252 lines
8.4 KiB
TypeScript
import { LitElement, html, css } from "lit";
|
|
import { customElement } from "lit/decorators/custom-element.js";
|
|
import { queryAll } from "lit/decorators/query-all.js";
|
|
import { query } from "lit/decorators/query.js";
|
|
import { words } from "./wordlist.js";
|
|
import "./fridge-tile.js";
|
|
import { property } from "lit/decorators/property.js";
|
|
import { FridgeTile } from "./fridge-tile.js";
|
|
import { pointerLocation } from "./events.js";
|
|
|
|
function* pointsIterator(source: FridgeTile[]) {
|
|
for (const tile of source) {
|
|
for (const point of tile.points) {
|
|
yield point;
|
|
}
|
|
}
|
|
}
|
|
|
|
type Point = [number, number];
|
|
|
|
const isHTMLElement = (v: unknown): v is HTMLElement =>
|
|
typeof v === "object" && v !== null && "nodeType" in v && v.nodeType === Node.ELEMENT_NODE;
|
|
|
|
@customElement("fridge-board")
|
|
export class FridgeBoard extends LitElement {
|
|
static get styles() {
|
|
return css`
|
|
:host {
|
|
display: block;
|
|
position: relative;
|
|
}
|
|
|
|
#fridge {
|
|
overflow: hidden;
|
|
position: relative;
|
|
top: 0;
|
|
right: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: transparent;
|
|
}
|
|
|
|
fridge-tile {
|
|
position: absolute;
|
|
transition:
|
|
scale 200ms ease-out,
|
|
rotate 200ms ease-out;
|
|
will-change: scale rotate translate;
|
|
}
|
|
|
|
.ready-to-slide {
|
|
transition:
|
|
scale 1500ms ease-out,
|
|
rotate 1500ms ease-out,
|
|
translate 1500ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
will-change: scale rotate translate;
|
|
}
|
|
`;
|
|
}
|
|
|
|
@property({ type: Array })
|
|
words: (typeof words)[] = [];
|
|
|
|
@queryAll("fridge-tile")
|
|
tiles!: FridgeTile[];
|
|
|
|
@query("#fridge")
|
|
fridge!: HTMLDivElement;
|
|
|
|
maxZ: number = 1;
|
|
|
|
assignNewPositions() {
|
|
const pointsAlreadyPositioned: Point[] = [];
|
|
const unpositionable: FridgeTile[] = [];
|
|
const { width: boardWidth, height: boardHeight } = this.getBoundingClientRect();
|
|
|
|
const setNewPosition = (tile: FridgeTile) => {
|
|
const { height: tileHeight, width: tileWidth } = tile.size;
|
|
|
|
for (let i = 0; i < 30; i++) {
|
|
// Where we will put the new tile
|
|
const newYPos = Math.random() * (boardHeight - tileHeight) * 0.96;
|
|
const newXPos = Math.random() * (boardWidth - tileWidth) * 0.96;
|
|
|
|
// The box defining the new tile.
|
|
const box = {
|
|
xl: newXPos,
|
|
xr: newXPos + tileWidth,
|
|
yt: newYPos,
|
|
yb: newYPos + tileHeight,
|
|
};
|
|
|
|
const isPointInBox = (point: Point) =>
|
|
point[0] >= box.xl && point[0] <= box.xr && point[1] >= box.yt && point[1] <= box.yb;
|
|
|
|
if (pointsAlreadyPositioned.find(p => isPointInBox(p)) === undefined) {
|
|
tile.style.top = `${box.yt}px`;
|
|
tile.style.left = `${box.xl}px`;
|
|
pointsAlreadyPositioned.push(
|
|
[box.xl, box.yt],
|
|
[box.xr, box.yt],
|
|
[box.xl, box.yb],
|
|
[box.xr, box.yb]
|
|
);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
for (const tile of this.tiles) {
|
|
if (!setNewPosition(tile)) {
|
|
unpositionable.push(tile);
|
|
}
|
|
}
|
|
|
|
for (const tile of unpositionable) {
|
|
tile.remove();
|
|
}
|
|
}
|
|
|
|
onPointerDown(ev: PointerEvent) {
|
|
const node = ev.target;
|
|
if (!(isHTMLElement(node) && node.tagName.toLowerCase() === "fridge-tile")) {
|
|
return;
|
|
}
|
|
|
|
// The position of the board relative to the viewport;
|
|
const { left: fridgeLeft, top: fridgeTop } = this.getBoundingClientRect();
|
|
|
|
// The position of the node relative to the viewport:
|
|
const { left: nodeLeft, top: nodeTop } = node.getBoundingClientRect();
|
|
|
|
// The position of the pointer when the event started relative to the viewport;
|
|
const { clientX: pointerStartX, clientY: pointerStartY } = ev;
|
|
let pointerX = pointerStartX;
|
|
let pointerY = pointerStartY;
|
|
|
|
// Starting position of the *node* with respect to the board;
|
|
const tileStart = {
|
|
x: nodeLeft - fridgeLeft,
|
|
y: nodeTop - fridgeTop,
|
|
};
|
|
|
|
const controller = new AbortController();
|
|
const { signal } = controller;
|
|
|
|
let tracking = true;
|
|
|
|
const pointer = (ev: PointerEvent) => {
|
|
const { clientX, clientY } = ev;
|
|
pointerX = clientX;
|
|
pointerY = clientY;
|
|
};
|
|
|
|
this.maxZ = this.maxZ + 1;
|
|
node.style.setProperty("z-index", `${this.maxZ}`);
|
|
node.style.setProperty("scale", "1.3");
|
|
node.style.setProperty("rotate", `${Math.random() * 30 - 15}deg`);
|
|
|
|
const stop = () => {
|
|
tracking = false;
|
|
controller.abort();
|
|
let delX = pointerX - pointerStartX;
|
|
let delY = pointerY - pointerStartY;
|
|
node.style.removeProperty("translate");
|
|
node.style.setProperty("top", `${tileStart.y + delY}px`);
|
|
node.style.setProperty("left", `${tileStart.x + delX}px`);
|
|
requestAnimationFrame(() => {
|
|
node.style.setProperty("scale", "1.0");
|
|
node.style.setProperty("rotate", `${Math.random() * 30 - 15}deg`);
|
|
});
|
|
};
|
|
|
|
let animationFrame: number = -1;
|
|
|
|
const move = () => {
|
|
if (tracking) {
|
|
let delX = pointerX - pointerStartX;
|
|
let delY = pointerY - pointerStartY;
|
|
node.style.setProperty("translate", `${+delX}px ${+delY}px 0`);
|
|
animationFrame = requestAnimationFrame(move);
|
|
return;
|
|
}
|
|
|
|
cancelAnimationFrame(animationFrame);
|
|
return;
|
|
};
|
|
|
|
window.addEventListener("pointermove", pointer, { signal });
|
|
window.addEventListener("pointerup", stop, { signal });
|
|
window.addEventListener("pointercancel", stop, { signal });
|
|
animationFrame = requestAnimationFrame(move);
|
|
}
|
|
|
|
render() {
|
|
return html`<div id="fridge" @pointerdown=${this.onPointerDown}>
|
|
${words.map(word => html`<fridge-tile style="opacity: 0" word=${word.w}></fridge-tile>`)}
|
|
</div>`;
|
|
}
|
|
|
|
updated() {
|
|
const { width: boardWidth, height: boardHeight } = this.getBoundingClientRect();
|
|
this.assignNewPositions();
|
|
|
|
const fd = (size: number) => {
|
|
const newDelta = 40 * Math.random();
|
|
return Math.random() < 0.5 ? newDelta + size : newDelta * -1;
|
|
};
|
|
|
|
const lastTile = this.tiles[this.tiles.length - 1];
|
|
|
|
const slideEnd = (ev: TransitionEvent) => {
|
|
if (ev.propertyName === "translate") {
|
|
this.tiles.forEach(tile => {
|
|
tile.classList.remove("ready-to-slide");
|
|
});
|
|
lastTile.removeEventListener("transitionend", slideEnd);
|
|
}
|
|
};
|
|
|
|
requestAnimationFrame(() => {
|
|
this.tiles.forEach(tile => {
|
|
const outerX = fd(boardWidth);
|
|
const outerY = fd(boardHeight);
|
|
const [tileX, tileY] = tile.relativePosition;
|
|
tile.style.setProperty("translate", `${outerX - tileX}px ${outerY - tileY}px 0`);
|
|
tile.style.setProperty("scale", "1.5");
|
|
tile.style.setProperty("rotate", `${Math.random() * 720}deg`);
|
|
tile.style.setProperty("opacity", "100%");
|
|
});
|
|
|
|
lastTile.addEventListener("transitionend", slideEnd);
|
|
|
|
requestAnimationFrame(() =>
|
|
this.tiles.forEach(tile => {
|
|
tile.classList.add("ready-to-slide");
|
|
tile.style.setProperty("translate", "0 0 0");
|
|
tile.style.setProperty("rotate", `${Math.random() * 30 - 15}deg`);
|
|
tile.style.setProperty("scale", "1.0");
|
|
})
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
"fridge-board": FridgeBoard;
|
|
}
|
|
}
|