Utility classes

2019
class Grid implements \JsonSerializable, \ArrayAccess
{
    public $rows, $columns, $cells, $width, $height;

    /**
     * Create a new Grid from a block of Scalar
     *
     * @param Scalar|string|array|Set $from
     * @param string|int $y_delimiter String delimiter or number of characters per cell
     * @param string|int $x_delimiter String delimiter for the rows
     */
    public function __construct($from = [], $x_delimiter = 1, $y_delimiter = "\n")
    {
        $from = is_string($from) ? scalar($from) : $from;

        if ($from instanceof Scalar) {
            $this->rows = $from->split($y_delimiter)->map(function($row) use ($x_delimiter) {
                return is_int($x_delimiter) ? $row->chunk($x_delimiter) : $row->split($x_delimiter);
            });
        } else {
            $this->rows = set($from);
        }
    }

    /**
     * When debugging, only output the value
     *
     * @return mixed
     */
    public function jsonSerialize(): mixed
    {
        return $this->fillVoid(" ")->rows->callEach()->join("")->values();
    }

    /**
     * Display the grid as a string
     *
     * @return string
     */
    public function __toString()
    {
        return $this->fillVoid(" ")->rows->callEach()->join("")->join("\n");
    }

    /**
     * Keep value but create a new instance for each row
     */
    public function __clone()
    {
        $this->rows = $this->rows->mapAssoc(fn ($y, $row) => [$y => set((array) $row)]);
    }

    /**
     * Dump the result of a model with all its fields loaded
     *
     * @return void
     */
    public function json()
    {
        \Syltaen\Log::json($this);
    }

    // =============================================================================
    // > ARRAY ACCESS
    // =============================================================================
    /** Impementation of ArrayAccess::offsetExists */
    public function offsetExists(mixed $offset): bool
    {
        return isset($this->rows[$offset]);
    }

    /** Impementation of ArrayAccess::offsetGet */
    public function offsetGet(mixed $offset): mixed
    {
        return $this->rows[$offset];
    }

    /** Impementation of ArrayAccess::offsetSet */
    public function offsetSet(mixed $offset, mixed $value): void
    {
        $this->rows[$offset] = $value;
    }

    /** Impementation of ArrayAccess::offsetUnset */
    public function offsetUnset(mixed $offset): void
    {
        unset($this->rows[$offset]);
    }

    // =============================================================================
    // > DYNAMIC PROPERTIES
    // =============================================================================
    /**
     * Get the rows of the grid
     *
     * @return Set
     */
    public function rows()
    {
        return $this->rows;
    }

    /**
     * Get the columns of the grid by transposing the rows
     *
     * @return Set
     */
    public function columns()
    {
        return $this->columns ??= $this->rows()->callEach()->keys()->merge()->unique()->sort()
        ->mapAssoc(fn ($i, $x) => [$x => $this->rows()->column($x)->filter(fn ($v) => $v !== null)]);
    }

    /**
     * List of all the cells of the grid
     *
     * @param Set $xys List of cells to get
     * @return Set
     */
    public function cells($xys = false)
    {
        // Get specific cells
        if (!empty($xys)) {
            $cells = set();
            foreach ($xys as $xy) $cells[implode(";", $xy)] = $this->cell($xy);
            return $cells;
        }

        // Cache all cells values
        if (empty($this->cells)) {
            $this->cells = set();

            foreach ($this->rows as $y=>$row) {
                foreach ($row as $x=>$cell) {
                    $this->cells["$x;$y"] = $cell;
                }
            }
        }

        return $this->cells;
    }

    /**
     * List of all defined cells keys
     *
     * @return Set of [x, y]
     */
    public function keys()
    {
        $keys = [];
        foreach ($this->rows as $y=>$row) {
            foreach ($row as $x=>$cell) $keys[] = [$x, $y];
        }
        return set($keys);
    }

    /**
     * Width of the grid
     *
     * @return int
     */
    public function width()
    {
        return $this->width ??= $this->columns()->keys()->max() - $this->columns()->keys()->min() + 1;
    }


    /**
     * Height of the grid
     *
     * @return int
     */
    public function height()
    {
        return $this->height ??= $this->rows()->keys()->max() - $this->rows()->keys()->min() + 1;
    }

    /**
     * Update the grid with a new set of rows
     * and clear cached data
     *
     * @return self
     */
    public function update($rows)
    {
        $this->rows = $rows;
        $this->clearCache();
        return $this;
    }

    /**
     * Clear all cached values
     *
     * @return self
     */
    public function clearCache()
    {
        unset($this->width, $this->height, $this->columns, $this->cells);
        return $this;
    }

    // =============================================================================
    // > PROPERTIES SHORTCUTS
    // =============================================================================
    /**
     * Get a specific row
     *
     * @param int $y
     * @return Set
     */
    public function row($y)
    {
        return $this->rows[$y];
    }

    /**
     * Get a specific column
     *
     * @param int $y
     * @return Set
     */
    public function column($x)
    {
        return $this->columns()[$x];
    }

    /**
     * Get a specific cell
     *
     * @param array|GridPointer $xy [x, y]
     * @return mixed
     */
    public function cell($xy)
    {
        return $this->rows[$xy[1]][$xy[0]] ?? null;
    }

    /**
     * Get one or several cells values
     *
     * @param array|GridPionter xy or array of xy
     * @return mixed
     */
    public function get($xy)
    {
        return $this->cell($xy);
    }

    /**
     * Check that a cell exists
     *
     * @param array|GridPointer $xy
     * @return boolean
     */
    public function has($xy)
    {
        return isset($this->rows[$xy[1]][$xy[0]]);
    }

    /**
     * Get the neighbors values of the given cell
     *
     * @param array|GridPointer $xy The given cell
     * @param boolean $diagonals
     * @return Set
     */
    public function getNeighbors($xy, $diagonals = false)
    {
        return $this->cells(xy($xy)->getNeighbors($diagonals));
    }

    /**
     * Wrap a xy position that would be outside the grid
     *
     * @param array $xy [x, y]
     * @param string $direction Wrap in a specific direction
     * @return array [x, y]
     */
    public function wrap($xy, $direction)
    {
        switch ($direction) {
            case "up":
                $column = $this->columns()[$xy[0]];
                $xy[1] = $xy[1] < $column->keys()->min() ? $column->keys()->max() : $xy[1];
                break;
            case "right":
                $row = $this[$xy[1]];
                $xy[0] = $xy[0] > $row->keys()->max() ? $row->keys()->min() : $xy[0];
                break;
            case "down":
                $column = $this->columns()[$xy[0]];
                $xy[1] = $xy[1] > $column->keys()->max() ? $column->keys()->min() : $xy[1];
                break;
            case "left":
                $row = $this[$xy[1]];
                $xy[0] = $xy[0] < $row->keys()->min() ? $row->keys()->max() : $xy[0];
                break;
            default:
                die("Direction not supported : {$direction}");
        }

        return $xy;
    }

    // =============================================================================
    // > PARTS
    // =============================================================================
    /**
     * Slice of a row/column from a specific point
     *
     * @param GridPointer $xy
     * @param string $direction up|right|down|left
     * @return Set
     */
    public function slice($xy, $direction)
    {
        switch ($direction) {
            case "up":
                $col = $this->columns()[$xy[0]];
                return $col->slice(0, $col->keys()->search($xy[1]))->reverse();
            case "right":
                $row = $this->rows[$xy[1]];
                return $row->slice($row->keys()->search($xy[0]) + 1);
            case "down":
                $col = $this->columns()[$xy[0]];
                return $col->slice($col->keys()->search($xy[1]) + 1);
            case "left":
                $row = $this->rows[$xy[1]];
                return $row->slice(0, $row->keys()->search($xy[0]))->reverse();
            default:
                die("Direction not supported : $direction");
        }
    }

    /**
     * Get a subgrid of this grid
     *
     * @param array $from XY top left
     * @param array $to XY bottom right
     * @return Grid
     */
    public function sub($from, $to)
    {
        $rows = $this->rows->slice($from[1], $to[1]);
        $rows = $rows->map(fn ($row) => $row->slice($from[0], $to[0]));
        return new Grid($rows);
    }

    // =============================================================================
    // > TOOLS
    // =============================================================================
    /**
     * Map the cells of the grid
     *
     * @param callable $callback
     * @return Grid
     */
    public function map($callback)
    {
        return new static($this->rows->map(function($row, $y) use ($callback) {
            return $row->map(function ($cell, $x) use ($callback, $y) {
                return $callback($cell, [$x, $y]);
            }, true);
        }, true));
    }

    /**
     * Return a CallableSet that allows to use a specific method on each element of this set.
     *
     * @return CallableMap
     */
    public function callEach($deepness = 1)
    {
        $cc = new CallableMap($this);

        if ($deepness == 1) {
            return $cc;
        }

        return $cc->callEach($deepness - 1)->callEach();
    }

    /**
     * Filter the cells of the grid
     *
     * @param callable $callback
     * @return Grid
     */
    public function filter($callback = "\Scalar::isNotEmpty")
    {
        return new static($this->rows->map(function($row, $y) use ($callback) {
            return $row->filter(function ($cell, $x) use ($callback, $y) {
                return $callback($cell, [$x, $y]);
            });
        }, true));
    }

    /**
     * Keep only the specified values
     *
     * @param  array $array
     * @return Grid
     */
    public function keep($array)
    {
        return $this->filter(fn ($cell) => in_array($cell, (array) $array));
    }

    /**
     * Remove the specified values
     *
     * @param  array $array
     * @return Grid
     */
    public function remove($array)
    {
        return $this->filter(fn ($cell) => !in_array($cell, (array) $array));
    }

    /**
     * Filter the cells of the grid
     *
     * @param callable $callback
     * @return mixed
     */
    public function reduce($callback, $init)
    {
        return $this->rows->reduce(function($carry, $row, $y) use ($callback) {
            return $row->reduce(function($carry, $cell, $x) use ($callback, $y) {
                return $callback($carry, $cell, [$x, $y]);
            }, $carry, true);
        }, $init, true);
    }

    /**
     * Repeat the grid in the x axis
     *
     * @return Grid
     */
    public function repeatX($times = 2)
    {
        return new static($this->rows->callEach()->repeat($times));
    }

    /**
     * Repeat the grid in the y axis
     *
     * @return Grid
     */
    public function repeatY($times = 2)
    {
        return new static($this->rows->repeat($times));
    }

    /**
     * Create all the missing cells with a specific value
     *
     * @return self
     */
    public function fillVoid($content = " ")
    {
        $xs = $this->rows()->callEach()->keys()->merge();
        $ys = $this->rows()->keys();

        for ($x = $xs->min(); $x <= $xs->max(); $x++) {
            for ($y = $ys->min(); $y <= $ys->max(); $y++) {
                if (!isset($this->rows[$y][$x])) {
                    $this->set([$x, $y], $content);
                }
                $this->rows[$y]->sortKeys();
            }
            $this->rows->sortKeys();
        }
        return $this;
    }

    /**
     * Draw a line between two points in the same row or column
     *
     * @param GridPointer $from
     * @param array $to
     * @return self
     */
    public function drawLine($from, $to, $content = "#")
    {
        foreach (xy($from)->pathTo($to) as $cell) {
            $this->set($cell, $content);
        }
        return $this;
    }

    /**
     * Draw the content of another grid inside this one
     *
     * @param array|GridPointer $xy
     * @param Grid $grid
     * @return self
     */
    public function insert($xy, $grid)
    {
        foreach ($grid->rows as $y=>$row) {
            foreach ($row as $x=>$cell) {
                $this->set([$x + $xy[0], $y + $xy[1]], $cell);
            }
        }

        return $this;
    }

    /**
     * Draw a circle in the grid
     *
     * @param GridPointer $center
     * @param int $radius
     * @return self
     */
    public function drawCircle($center, $radius, $content = "#")
    {
        foreach ((new GridCircle($center, $radius))->getPoints() as $xy) {
            $this->set($xy, $content);
        }
        return $this;
    }

    /**
     * Return the index of the first match
     *
     * @param mixed $search
     * @return array
     */
    public function search($search)
    {
        foreach ($this->rows as $y => $row) {
            if (($x = $row->search($search)) !== false) {
                return [$x, $y];
            }
        }
        return false;
    }

    /**
     * Return the index of all the matches
     *
     * @param mixed $search
     * @return array
     */
    public function searchAll($search)
    {
        $indexes = [];
        foreach ($this->rows as $y => $row) {
            foreach ($row as $x=>$value) {
                if ($value == $search) {
                    $indexes[] = [$x, $y];
                }
            }
        }
        return $indexes;
    }

    /**
     * Set the value of a cell
     *
     * @param array|GridPointer $xy
     * @param mixed $value
     * @return self
     */
    public function set($xy, $value)
    {
        if (!isset($this->rows[$xy[1]])) {
            $this->rows[$xy[1]] = set([]);
        }
        $this->rows[$xy[1]][$xy[0]] = $value;
        // $this->clearCache();
        return $this;
    }

    /**
     * Set all values of a specific row
     *
     * @param $int $y
     * @param Set|array $row
     * @return self
     */
    public function setRow($y, $row)
    {
        $this->rows[$y] = set($row);
        return $this;
    }

    /**
     * Set all values of a specific column
     *
     * @param $int $x
     * @param Set|array $row
     * @return self
     */
    public function setColumn($x, $column)
    {
        foreach ($column as $y => $value) {
            $this->set([$x, $y], $value);
        }
        return $this;
    }


    /**
     * Delete a cell
     *
     * @param GridPointer $xy
     * @return self
     */
    public function unset($xy)
    {
        if (isset($this->rows[$xy[1]][$xy[0]])) {
            unset($this->rows[$xy[1]][$xy[0]]);
        }
        $this->clearCache();
        return $this;
    }

    /**
     * Offset the whole grid
     *
     * @param GridPonter $xy
     * @return self
     */
    public function move($xy)
    {
        $this->rows = $this->rows->mapAssoc(function ($y, $row) use ($xy) {
            return [$y + $xy[1] => $row->mapAssoc(fn ($x, $cell) => [$x + $xy[0] => $cell])];
        });
        return $this;
    }

    /**
     * Reverse on the X axis
     *
     * @return self
     */
    public function reverseX()
    {
        $this->rows = $this->rows->map(fn ($r) => $r->reverse());
        return $this;
    }

    /**
     * Reverse on the Y axis
     *
     * @return self
     */
    public function reverseY()
    {
        $this->rows = $this->rows->reverse();
        return $this;
    }

    /**
     * Rotate the grid X times by 90° clockwise
     *
     * @param integer $times
     * @return self
     */
    public function rotate($times = 1)
    {
        for ($i = 0; $i < $times; $i++) {
            $this->rows = $this->columns();
            $this->reverseX();
            $this->clearCache();
        }

        return $this;
    }

    /**
     * Check if the grid has collisions with another
     *
     * @return boolean|int The number of collisons
     */
    public function hasCollision($grid)
    {
        return $this->cells()->keys()->keep($grid->cells()->keys())->count();
    }

    /**
     * Create a graph of the grid, with each item as a node
     *
     * @param callback $edge_selector
     * @return Graph
     */
    public function getGraph($edge_selector = false)
    {
        // Default selection : direct neighbors that exist in the grid
        if (!$edge_selector) {
            $edge_selector = function ($cell, $xy, $grid) {
                return $xy->getNeighbors()->filter(fn ($xy) => $grid->get($xy))->mapAssoc(function ($i, $xy) {
                    return [(string) $xy => 1];
                });
            };
        }

        $nodes = $this->map(function ($cell, $xy) use ($edge_selector) {
            return $edge_selector($cell, $xy, $this);
        })->cells()->filter();

        return new Graph($nodes);
    }
}