Skip to content

Commit

Permalink
Merge pull request #10 from thecrypticace/feature/visualization
Browse files Browse the repository at this point in the history
Visualization helper
  • Loading branch information
brendt authored Dec 19, 2018
2 parents aa560ec + 7a45257 commit acc50ce
Show file tree
Hide file tree
Showing 3 changed files with 439 additions and 0 deletions.
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,41 @@ Period::make(Carbon::make('2018-01-01'), Carbon::make('2018-01-02'));
Note that as soon as a period is constructed, all further operations on it are immutable.
There's never the danger of changing the input dates.

### Visualizing periods

You can visualize one or more `Period` objects as well as `PeriodCollection`
objects to see how they related to one another:

```php
$visualizer = new Visualizer(["width" => 27]);
$visualizer->visualize([
"A" => Period::make('2018-01-01', '2018-01-31'),
"B" => Period::make('2018-02-10', '2018-02-20'),
"C" => Period::make('2018-03-01', '2018-03-31'),
"D" => Period::make('2018-01-20', '2018-03-10'),
"OVERLAP" => new PeriodCollection(
Period::make('2018-01-20', '2018-01-31'),
Period::make('2018-02-10', '2018-02-20'),
Period::make('2018-03-01', '2018-03-10')
),
]);
```

And visualize will return the following string:
```
A [========]
B [==]
C [========]
D [==============]
OVERLAP [===] [==] [==]
```

The visualizer has a configurable width provided upon creation
which will control the bounds of the displayed periods:
```php
$visualizer = new Visualizer(["width" => 10]);
```

### Testing

``` bash
Expand Down
221 changes: 221 additions & 0 deletions src/Visualizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
<?php

namespace Spatie\Period;

class Visualizer
{
/**
* Options used in configuring the visualization.
*
* - int width:
* Determines the output size of the visualization
* Note: This controls the width of the bars only.
*
* @var array
*/
private $options;

/**
* Create a new visualizer.
*
* @param array $options
*/
public function __construct(array $options = [])
{
$this->options = $options;
}

/**
* Builds a string to visualize one or more
* periods and/or collections in a more
* human readable / parsable manner.
*
* Keys are used as identifiers in the output
* and the periods are represented with bars.
*
* This visualizer is capable of generating
* output like the following:
*
* A [========]
* B [==]
* C [=====]
* CURRENT [===============]
* OVERLAP [=] [==] [=]
*
* @param array|Period[]|PeriodCollection[] $blocks
* @return string
*/
public function visualize(array $blocks): string
{
$matrix = $this->matrix($blocks);

$nameLength = max(...array_map('strlen', array_keys($matrix)));

$lines = [];

foreach ($matrix as $name => $row) {
$lines[] = vsprintf('%s %s', [
str_pad($name, $nameLength, ' '),
$this->toBars($row),
]);
}

return implode("\n", $lines);
}

/**
* Build a 2D table such that:
* - There's one row for every block.
* - There's one column for every unit of width.
* - Each cell is true when a period is active for that unit.
* - Each cell is false when a period is not active for that unit.
*
* @param array $blocks
* @return array
*/
private function matrix(array $blocks): array
{
$width = $this->options['width'];

$matrix = array_fill(0, count($blocks), array_fill(0, $width, false));
$matrix = array_combine(array_keys($blocks), array_values($matrix));

$bounds = $this->bounds($blocks);

foreach ($blocks as $name => $block) {
if ($block instanceof Period) {
$matrix[$name] = $this->populateRow($matrix[$name], $block, $bounds);
} elseif ($block instanceof PeriodCollection) {
foreach ($block as $period) {
$matrix[$name] = $this->populateRow($matrix[$name], $period, $bounds);
}
}
}

return $matrix;
}

/**
* Get the start / end coordinates for a given period.
*
* @param Period $period
* @param Period $bounds
* @param int $width
* @return array
*/
private function coords(Period $period, Period $bounds, int $width): array
{
$boundsStart = $bounds->getStart()->getTimestamp();
$boundsEnd = $bounds->getEnd()->getTimestamp();
$boundsLength = $boundsEnd - $boundsStart;

// Get the bounds
$start = $period->getStart()->getTimestamp() - $boundsStart;
$end = $period->getEnd()->getTimestamp() - $boundsStart;

// Rescale from timestamps to width units
$start *= $width / $boundsLength;
$end *= $width / $boundsLength;

// Cap at integer intervals
$start = floor($start);
$end = ceil($end);

return [$start, $end];
}

/**
* Populate a row with true values
* where periods are active.
*
* @param array $row
* @param Period $period
* @param Period $bounds
* @return array
*/
private function populateRow(array $row, Period $period, Period $bounds): array
{
$width = $this->options['width'];

[$startIndex, $endIndex] = $this->coords($period, $bounds, $width);

for ($i = 0; $i < $width; $i++) {
if ($startIndex <= $i && $i < $endIndex) {
$row[$i] = true;
}
}

return $row;
}

/**
* Get the bounds encompassing all visualized periods.
*
* @param array $blocks
* @return Period|null
*/
private function bounds(array $blocks): ?Period
{
$periods = new PeriodCollection();

foreach ($blocks as $block) {
if ($block instanceof Period) {
$periods[] = $block;
} elseif ($block instanceof PeriodCollection) {
foreach ($block as $period) {
$periods[] = $period;
}
}
}

return $periods->boundaries();
}

/**
* Turn a series of true/false values into bars
* representing the start/end of periods.
*
* @param array $row
* @return string
*/
private function toBars(array $row): string
{
$tmp = '';

for ($i = 0, $l = count($row); $i < $l; $i++) {
$prev = $row[$i - 1] ?? null;
$curr = $row[$i];
$next = $row[$i + 1] ?? null;

// Small state machine to build the string
switch (true) {
// The current period is only one unit long so display a "="
case $curr && $curr !== $prev && $curr !== $next:
$tmp .= '=';
break;

// We've hit the start of a period
case $curr && $curr !== $prev && $curr === $next:
$tmp .= '[';
break;

// We've hit the end of the period
case $curr && $curr !== $next:
$tmp .= ']';
break;

// We're adding segments to the current period
case $curr && $curr === $prev:
$tmp .= '=';
break;

// Otherwise it's just empty space
default:
$tmp .= ' ';
break;
}
}

return $tmp;
}
}
Loading

0 comments on commit acc50ce

Please sign in to comment.