thi-ng/umbrella

View on GitHub
packages/layout/src/grid-layout.ts

Summary

Maintainability
A
1 hr
Test Coverage
import { isNumber } from "@thi.ng/checks/is-number";
import type { CellSpan, IGridLayout, LayoutBox } from "./api.js";

/** @internal */
export const __DEFAULT_SPANS: CellSpan = [1, 1];

export class GridLayout implements IGridLayout<GridLayout> {
    readonly parent: GridLayout | null;
    readonly cols: number;
    readonly width: number;
    readonly x: number;
    readonly y: number;
    readonly cellW: number;
    readonly cellH: number;
    readonly cellWG: number;
    readonly cellHG: number;
    readonly gap: number;

    protected currCol: number;
    protected currRow: number;
    protected rows: number;

    constructor(
        parent: GridLayout | null,
        x: number,
        y: number,
        width: number,
        cols: number,
        rowH: number,
        gap: number
    ) {
        this.parent = parent;
        this.cols = cols;
        this.x = x;
        this.y = y;
        this.width = width;
        this.cellW = (width - (cols - 1) * gap) / cols;
        this.cellH = rowH;
        this.cellWG = this.cellW + gap;
        this.cellHG = rowH + gap;
        this.gap = gap;
        this.currCol = 0;
        this.currRow = 0;
        this.rows = 0;
    }

    colsForWidth(w: number) {
        return Math.ceil(w / this.cellWG);
    }

    rowsForHeight(h: number) {
        return Math.ceil(h / this.cellHG);
    }

    spanForSize(size: ArrayLike<number>): CellSpan;
    spanForSize(w: number, h: number): CellSpan;
    spanForSize(w: ArrayLike<number> | number, h?: number): CellSpan {
        const size = isNumber(w) ? [w, h!] : w;
        return [this.colsForWidth(size[0]), this.rowsForHeight(size[1])];
    }

    next(spans = __DEFAULT_SPANS) {
        const { cellWG, cellHG, gap, cols } = this;
        const cspan = Math.min(spans[0], cols);
        const rspan = spans[1];
        if (this.currCol > 0) {
            if (this.currCol + cspan > cols) {
                this.currCol = 0;
                this.currRow = this.rows;
            }
        } else {
            this.currRow = this.rows;
        }
        const h = rspan * cellHG - gap;
        const cell: LayoutBox = {
            x: this.x + this.currCol * cellWG,
            y: this.y + this.currRow * cellHG,
            w: cspan * cellWG - gap,
            h,
            cw: this.cellW,
            ch: this.cellH,
            gap,
            span: [cspan, rspan],
        };
        this.propagateSize(rspan);
        this.currCol = Math.min(this.currCol + cspan, cols) % cols;
        return cell;
    }

    // TODO add optional colspan arg, fix rounding
    nextSquare() {
        const box = this.next([
            1,
            Math.ceil(this.cellW / (this.cellH + this.gap)) + 1,
        ]);
        box.h = box.w;
        return box;
    }

    nest(cols: number, spans?: CellSpan, gap = this.gap): GridLayout {
        const { x, y, w } = this.next(spans);
        return new GridLayout(this, x, y, w, cols, this.cellH, gap);
    }

    /**
     * Updates max rows used in this layout and all of its parents.
     *
     * @param rspan -
     */
    propagateSize(rspan: number) {
        let rows = this.rows;
        this.rows = rows = Math.max(rows, this.currRow + rspan);
        const parent = this.parent;
        parent && parent.propagateSize(rows);
    }
}

/**
 * Syntax sugar for {@link GridLayout} ctor. By default creates a
 * single-column layout at given position and width.
 *
 * @param x -
 * @param y -
 * @param width -
 * @param cols -
 * @param rowH -
 * @param gap -
 */
export const gridLayout = (
    x: number,
    y: number,
    width: number,
    cols = 1,
    rowH = 16,
    gap = 4
) => new GridLayout(null, x, y, width, cols, rowH, gap);