@hydroperx/tiles
Version:
Metro tile layout
632 lines (631 loc) • 20.8 kB
JavaScript
// third-party imports
import assert from "assert";
// local imports
import { EMObserver } from "./utils/EMObserver";
import { getWidth, getHeight, } from "./enum/TileSize";
import * as ScaleUtils from "./utils/ScaleUtils";
import { State } from "./State";
import { HorizontalLayout } from "./HorizontalLayout";
import { VerticalLayout } from "./VerticalLayout";
import { GroupFactory } from "./GroupFactory";
import { TileFactory } from "./TileFactory";
import * as Attributes from "./Attributes";
/**
* Tiles layout.
*/
export class Tiles extends EventTarget {
constructor(params) {
super();
/** @hidden */
Object.defineProperty(this, "_state", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** @hidden */
Object.defineProperty(this, "_container", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** @hidden */
Object.defineProperty(this, "_dir", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** @hidden */
Object.defineProperty(this, "_class_names", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** @hidden */
Object.defineProperty(this, "_small_size", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** @hidden */
Object.defineProperty(this, "_tile_gap", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** @hidden */
Object.defineProperty(this, "_group_gap", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** @hidden */
Object.defineProperty(this, "_group_width", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** @hidden */
Object.defineProperty(this, "_inline_groups", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** @hidden */
Object.defineProperty(this, "_height", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** @hidden */
Object.defineProperty(this, "_label_height", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** @hidden */
Object.defineProperty(this, "_rearrange_timeout", {
enumerable: true,
configurable: true,
writable: true,
value: -1
});
/** @hidden */
Object.defineProperty(this, "_state_update_timeout", {
enumerable: true,
configurable: true,
writable: true,
value: -1
});
/** @hidden */
Object.defineProperty(this, "_drag_enabled", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** @hidden */
Object.defineProperty(this, "_selection_enabled", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** @hidden */
Object.defineProperty(this, "_em_observer", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** @hidden */
Object.defineProperty(this, "_em", {
enumerable: true,
configurable: true,
writable: true,
value: 16
});
/** @hidden */
Object.defineProperty(this, "_layout", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** @hidden */
Object.defineProperty(this, "_buttons", {
enumerable: true,
configurable: true,
writable: true,
value: new Map()
});
/** @hidden */
Object.defineProperty(this, "_group_draggables", {
enumerable: true,
configurable: true,
writable: true,
value: new Map()
});
/** @hidden */
Object.defineProperty(this, "_tile_draggables", {
enumerable: true,
configurable: true,
writable: true,
value: new Map()
});
/** @hidden */
Object.defineProperty(this, "_tile_drag_end_handlers", {
enumerable: true,
configurable: true,
writable: true,
value: new WeakMap()
});
/** @hidden */
Object.defineProperty(this, "_resize_observer", {
enumerable: true,
configurable: true,
writable: true,
value: null
});
/** @hidden */
Object.defineProperty(this, "_tile_removal_work", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** @hidden */
Object.defineProperty(this, "_group_removal_work", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/**
* Tile sizes in the cascading `em` unit.
* @hidden
*/
Object.defineProperty(this, "_tile_em", {
enumerable: true,
configurable: true,
writable: true,
value: {
small: { w: 0, h: 0 },
medium: { w: 0, h: 0 },
wide: { w: 0, h: 0 },
large: { w: 0, h: 0 },
}
});
this._container = params.element;
this._dir = params.direction;
this._class_names = {
group: params.classNames.group,
groupLabel: params.classNames.groupLabel,
groupLabelText: params.classNames.groupLabelText,
groupTiles: params.classNames.groupTiles,
tile: params.classNames.tile,
tileContent: params.classNames.tileContent,
};
this._small_size = params.smallSize;
this._tile_gap = params.tileGap;
this._group_gap = params.groupGap;
this._container.style.position = "relative";
this._tile_em.small.w = this._small_size;
this._tile_em.small.h = this._small_size;
this._tile_em.medium.w = this._small_size * 2 + this._tile_gap;
this._tile_em.medium.h = this._tile_em.medium.w;
this._tile_em.wide.w = this._tile_em.medium.w * 2 + this._tile_gap;
this._tile_em.wide.h = this._tile_em.medium.w;
this._tile_em.large.w = this._tile_em.wide.w;
this._tile_em.large.h = this._tile_em.wide.w;
// Removal works
this._tile_removal_work = params.tileRemovalWork;
this._group_removal_work = params.groupRemovalWork;
// dragEnabled
this._drag_enabled = params.dragEnabled ?? true;
// selectionEnabled
this._selection_enabled = params.selectionEnabled ?? true;
this._group_width = params.groupWidth ?? 6;
this._inline_groups = params.inlineGroups ?? 1;
this._height = params.height ?? 6;
this._label_height = params.labelHeight;
// Height >= 4 assertion
assert(this._dir == "horizontal" ? this._height >= 4 : true, "Tiles.height must be >= 4.");
// Group width >= 4 assertion
assert(this._dir == "vertical" ? this._group_width >= 4 : true, "Tiles.groupWidth must be >= 4.");
// Inline groups assertion
assert(this._dir == "vertical" ? this._inline_groups >= 1 : true, "Tiles.inlineGroups must be >= 1.");
// Observe the `em` unit size
this._em_observer = new EMObserver(this._container, (val) => {
this._em = val;
});
// Set state
this._state = new State();
// Initial layout
this._layout =
this._dir == "horizontal"
? new HorizontalLayout(this)
: new VerticalLayout(this);
// Rearrange
this._layout.rearrange();
}
/**
* The overall tiles state.
*/
get state() {
return this._state;
}
/**
* Clears everything.
*/
clear() {
// Clear state
this.state.clear();
// Clear layout measurements
this._layout.groups.length = 0;
// Discard draggables
for (const [, draggable] of this._group_draggables) {
draggable.destroy();
}
for (const [, draggable] of this._tile_draggables) {
draggable.destroy();
}
this._group_draggables.clear();
this._tile_draggables.clear();
// Clear button mappings
this._buttons.clear();
// Remove children
for (const child of Array.from(this._container.children)) {
if (child.classList.contains(EMObserver.CLASS)) {
continue;
}
child.remove();
}
// Rearrange layout
this._deferred_rearrange();
this._deferred_state_update_signal();
}
/**
* Destroys the `Tiles` instance, disposing
* of any observers and removing the container from the DOM.
*/
destroy(removeFromDOM = true) {
this._em_observer.cleanup();
this._resize_observer?.disconnect();
if (removeFromDOM) {
this._container.remove();
}
// Discard draggables
for (const [, draggable] of this._group_draggables) {
draggable.destroy();
}
for (const [, draggable] of this._tile_draggables) {
draggable.destroy();
}
this._group_draggables.clear();
this._tile_draggables.clear();
// Discard deferred rearrange
if (this._rearrange_timeout != -1) {
window.clearTimeout(this._rearrange_timeout);
}
}
/**
* Adds a group to the end and returns its `div` element.
* @throws If group ID is duplicate.
*/
addGroup(params) {
new GroupFactory(this).add(params);
}
/**
* Removes a group.
* @throws If the group does not exist.
*/
removeGroup(id) {
new GroupFactory(this).remove(id);
}
/**
* Attempts to add a tile.
*
* If both `x` and `y` are null, this method always succeeds,
* adding the tile to the best position available.
*
* @throws If tile ID is duplicate.
* @throws If group is specified and does not exist.
* @throws If either of `x` and `y` are `null`, but not both.
* @returns `true` if successfully added tile; `false` otherwise.
* It can fail depending on the `x` and `y` parameters.
*/
addTile(params) {
return new TileFactory(this).add(params);
}
/**
* Removes a tile.
* @throws If the tile does not exist.
*/
removeTile(id) {
new TileFactory(this).remove(id);
}
/**
* Renames a group.
*/
renameGroup(id, label) {
// Affect state
const group_state = this._state.groups.get(id);
assert(!!group_state, "Group '" + id + "' not found.");
group_state.label = label;
// Affect the DOM
const layout_group = this._layout.groups.find(group => group.id == id);
assert(!!layout_group, "Group '" + id + "' not found.");
if (layout_group.div) {
const textElement = layout_group.div
.getElementsByClassName(this._class_names.groupLabelText)[0];
textElement.innerText = label;
}
// State update signal
this._deferred_state_update_signal();
}
/**
* Attempts to resize a tile.
*/
resizeTile(id, size) {
// Fail if currently dragging a tile.
const tiles_dragging = Array.from(this._container.getElementsByClassName(this._class_names.tile))
.some(button => button.getAttribute(Attributes.ATTR_DRAGGING) == "true");
if (tiles_dragging) {
return false;
}
// Layout tile
const layout_group = this._layout.groups.find(group => group.hasTile(id));
assert(!!layout_group, "Tile '" + id + "' not found.");
const layout_tile = layout_group.getTile(id);
// Attempt resize
if (!layout_tile.resize(getWidth(size), getHeight(size))) {
return false;
}
// Update state
const tile_state = this._state.tiles.get(id);
assert(!!tile_state, "Tile '" + id + "' not found.");
tile_state.size = size;
// Update size attribute
layout_tile.button?.setAttribute(Attributes.ATTR_SIZE, size);
// Rearrange
this._deferred_rearrange();
// State update signal
this._deferred_state_update_signal();
return true;
}
/**
* Attempts to move a tile.
* @param x X coordinate in small tiles unit (1x1).
* @param y Y coordinate in small tiles unit (1x1).
*/
moveTile(id, x, y) {
// Fail if currently dragging a tile.
const tiles_dragging = Array.from(this._container.getElementsByClassName(this._class_names.tile))
.some(button => button.getAttribute(Attributes.ATTR_DRAGGING) == "true");
if (tiles_dragging) {
return false;
}
// Layout tile
const layout_group = this._layout.groups.find(group => group.hasTile(id));
assert(!!layout_group, "Tile '" + id + "' not found.");
const layout_tile = layout_group.getTile(id);
// Attempt resize
if (!layout_tile.move(x, y)) {
return false;
}
// Update state
const tile_state = this._state.tiles.get(id);
assert(!!tile_state, "Tile '" + id + "' not found.");
// Rearrange
this._deferred_rearrange();
return true;
}
/**
* Returns which tiles are checked.
*/
checkedTiles() {
const all_buttons = [
...this._container.getElementsByClassName(this._class_names.tile),
].map(btn => [
btn.getAttribute(Attributes.ATTR_ID),
btn.getAttribute(Attributes.ATTR_CHECKED) == "true",
]);
return all_buttons.filter(([, y]) => y).map(([id]) => id);
}
/**
* Returns whether a tile is checked or not.
*/
getChecked(tile) {
const button = this._buttons.get(tile);
assert(!!button, "Tile '" + tile + "' not found.");
return button.getAttribute(Attributes.ATTR_CHECKED) == "true";
}
/**
* Sets whether a tile is checked or not.
*/
setChecked(tile, value) {
const button = this._buttons.get(tile);
assert(!!button, "Tile '" + tile + "' not found.");
button.setAttribute(Attributes.ATTR_CHECKED, value.toString());
const all_buttons = [
...this._container.getElementsByClassName(this._class_names.tile),
].map(btn => [
btn.getAttribute(Attributes.ATTR_ID),
btn.getAttribute(Attributes.ATTR_CHECKED) == "true",
]);
// Emit selectionchange event
this.dispatchEvent(new CustomEvent("selectionchange", {
detail: {
tiles: all_buttons.filter(([, y]) => y).map(([id]) => id),
}
}));
}
/**
* Toggles whether a tile is checked or not.
*/
toggleChecked(tile) {
this.setChecked(tile, !this.getChecked(tile));
}
on(type, listenerFn, options) {
this.addEventListener(type, listenerFn, options);
}
off(type, listenerFn, options) {
this.removeEventListener(type, listenerFn, options);
}
/**
* Returns the number of inline groups available for
* the given width (either in `px` or `em`).
* *Applies to vertical layouts only.*
*
* @throws If not in a vertical layout.
*/
inlineGroupsAvailable(width) {
assert(this._dir == "vertical", "Tiles.inlineGroupsAvailable() can only be called on vertical layouts.");
const unitMatch = width.match(/(px|em)$/i);
assert(!!unitMatch, "Tiles.inlineGroupsAvailable() takes a width with a 'px' or 'em' unit.");
const unit = unitMatch[1].toLowerCase();
let w = parseFloat(width);
// convert px to em
if (unit == "px") {
w /= this._em;
}
let r = 0;
for (let acc = 0; acc < w; r++) {
if (acc != 0) {
acc += this._group_gap;
}
acc += this._group_width * this._small_size + (this._group_width - 1) * this._group_gap;
}
return r;
}
/**
* Indicates the number of inline groups in a vertical layout.
*
* @throws If not in a vertical layout.
*/
get inlineGroups() {
return this._inline_groups;
}
set inlineGroups(val) {
assert(this._dir == "vertical", "Tiles.inlineGroups can only be changed on vertical layouts.");
this._inline_groups = val;
this._deferred_rearrange();
}
/**
* Rearranges the layout.
*
* This call may be necessary if the container is scaled to zero, usable
* after the scale is greater than zero.
*/
rearrange() {
this._deferred_rearrange();
}
/**
* Rearranges the layout when the minimum scale to make it work
* is reached.
*
* This call may be necessary if the container is initially scaled to zero.
*/
rearrangeOverMinimumScale() {
const initialScale = ScaleUtils.getScale(this._container);
const abortController = new AbortController();
const check = () => {
if (abortController.signal.aborted) {
return;
}
const scale = ScaleUtils.getScale(this._container);
if (scale.x >= 0.1 && scale.y >= 0.1) {
this.rearrange();
return;
}
else if (scale.x < initialScale.x || scale.y < initialScale.y) {
return;
}
requestAnimationFrame(check);
};
requestAnimationFrame(check);
return abortController;
}
/** @hidden */
_keep_groups_contiguous() {
const sorted_groups = Array.from(this._state.groups.entries()).sort((a, b) => a[1].index - b[1].index);
let changed = false;
for (let i = 0; i < sorted_groups.length; i++) {
const group = sorted_groups[i][1];
if (group.index != i)
changed = true;
group.index = i;
}
if (changed)
this._deferred_state_update_signal();
}
/** @hidden */
_deferred_rearrange() {
if (this._rearrange_timeout != -1) {
window.clearTimeout(this._rearrange_timeout);
}
this._rearrange_timeout = window.setTimeout(() => {
this._layout.rearrange();
}, 0);
}
/** @hidden */
_deferred_state_update_signal() {
if (this._state_update_timeout != -1) {
window.clearTimeout(this._state_update_timeout);
}
this._state_update_timeout = window.setTimeout(() => {
this._state_update_signal();
}, 0);
}
/** @hidden */
_state_update_signal() {
this.dispatchEvent(new CustomEvent("stateupdate", { detail: this._state }));
}
}
/**
* Attribute name used for identifying a tile's ID.
*/
Object.defineProperty(Tiles, "ATTR_ID", {
enumerable: true,
configurable: true,
writable: true,
value: Attributes.ATTR_ID
});
/**
* Attribute name used for indicating a tile's size.
*/
Object.defineProperty(Tiles, "ATTR_SIZE", {
enumerable: true,
configurable: true,
writable: true,
value: Attributes.ATTR_SIZE
});
/**
* Attribute name used for indicating that a tile is actively in drag.
*/
Object.defineProperty(Tiles, "ATTR_DRAGGING", {
enumerable: true,
configurable: true,
writable: true,
value: Attributes.ATTR_DRAGGING
});
/**
* Attribute name used for indicating that a tile is checked.
*/
Object.defineProperty(Tiles, "ATTR_CHECKED", {
enumerable: true,
configurable: true,
writable: true,
value: Attributes.ATTR_CHECKED
});