Guides
Dashboards with Livewire
This page builds a small dashboard of linked charts. A Livewire component holds the filters, phplyr summarizes a phibble table on the server, and phiz renders three charts that all follow the same state. Changing a filter re-renders the component, and each chart re-embeds in place.
Prerequisites
- a Laravel application with Livewire 3, which provides the
#[Url]attribute used below - phiz, phibble, and phplyr:
composer require phpstats/phiz phpstats/phibble phpstats/phplyr
The chart code is examples/dashboard.php, with a method-chain version in examples/dashboard.chain.php. The test suite checks that both build the same charts.
The charts
fuel_dashboard() takes the cars table and the filter state and returns three plots and the class summary behind the bar chart. The Livewire component below calls it from render().
- The bar chart shows mean highway mpg by class, from phplyr’s
group_by()andsummarize(). The selected class is highlighted with a computed field. - The scatterplot shows the filtered cars, with one linear fit across all of them.
- The trend chart compares each manufacturer’s mean in 1999 and 2008. It ignores the year filter, so the comparison stays visible.
/*
* The fuel economy dashboard from the "Dashboards with Livewire" documentation page: three charts built from phplyr
* summaries of one filtered phibble table. A Livewire component calls fuel_dashboard() in render() with its public
* properties, so every chart follows the same filters.
*/
use Phibble\Contracts\Table;
use Phiz\Plot;
use function Phiz\aes;
use function Phplyr\{arrange, desc, filter, group_by, mean, n, summarize};
/**
* @param string|null $drive 'f', 'r', '4', or null for all
* @param int|null $year 1999, 2008, or null for both
* @return array{summary: Table, bars: Plot, scatter: Plot, trend: Plot}
*/
function fuel_dashboard(Table $cars, ?string $drive = null, ?int $year = null, ?string $selected = null): array
{
$byDrive = $cars |> filter(fn ($r) => $drive === null || $r->drv === $drive);
$subset = $byDrive |> filter(fn ($r) => $year === null || $r->year === $year);
$byClass = $subset |>
group_by('class') |>
summarize(cars: n(), hwy: mean('hwy'), cty: mean('cty')) |>
arrange(desc('hwy'));
// The trend compares the two years, so it ignores the year filter.
$byManufacturer = $byDrive |>
group_by('manufacturer', 'year') |>
summarize(hwy: mean('hwy'));
$bars = Plot::of($byClass)
->aes(x: 'hwy', y: \Phiz\field('class', sort: '-x'), fill: \Phiz\calc('datum.class === '.json_encode($selected ?? ''), 'selected', 'nominal'))
->col()
->scaleFill(domain: [false, true], range: ['#9ECAE1', '#08519C'], guide: false)
->tooltip(['class', 'cars', 'hwy', 'cty'])
->labs(title: 'Mean highway mpg by class', x: 'Highway mpg', y: null)
->size(320, 220);
$scatter = Plot::of($subset)
->aes(x: 'displ', y: 'hwy', color: 'class')
->point(alpha: 0.7, size: 40)
->smooth(aes(x: 'displ', y: 'hwy'), method: 'lm', color: '#333333', inheritAes: false)
->tooltip(['manufacturer', 'model', 'year', 'displ', 'hwy'])
->theme(legendPosition: 'bottom')
->labs(title: 'Displacement and highway mpg', x: 'Displacement (L)', y: 'Highway mpg', color: null)
->size(320, 220);
$trend = Plot::of($byManufacturer)
->aes(x: 'year:O', y: 'hwy', group: 'manufacturer')
->line(color: '#BBBBBB')
->point(color: '#666666', size: 20)
->tooltip(['manufacturer', 'year', 'hwy'])
->labs(title: 'Manufacturers, 1999 to 2008', x: null, y: 'Mean highway mpg')
->size(200, 220);
return ['summary' => $byClass, 'bars' => $bars, 'scatter' => $scatter, 'trend' => $trend];
}/*
* The fuel economy dashboard from the "Dashboards with Livewire" documentation page, written with method chains for
* PHP 8.3 and 8.4. examples/dashboard.php is the same code with the pipe operator; the tests check that both build the
* same charts.
*/
use Phibble\Contracts\Table;
use Phiz\Plot;
use function Phiz\aes;
use function Phplyr\{desc, mean, n, phplyr};
/**
* @param string|null $drive 'f', 'r', '4', or null for all
* @param int|null $year 1999, 2008, or null for both
* @return array{summary: Table, bars: Plot, scatter: Plot, trend: Plot}
*/
function fuel_dashboard(Table $cars, ?string $drive = null, ?int $year = null, ?string $selected = null): array
{
$byDrive = phplyr($cars)->filter(fn ($r) => $drive === null || $r->drv === $drive)->get();
$subset = phplyr($byDrive)->filter(fn ($r) => $year === null || $r->year === $year)->get();
$byClass = phplyr($subset)
->groupBy('class')
->summarize(cars: n(), hwy: mean('hwy'), cty: mean('cty'))
->arrange(desc('hwy'))
->get();
// The trend compares the two years, so it ignores the year filter.
$byManufacturer = phplyr($byDrive)
->groupBy('manufacturer', 'year')
->summarize(hwy: mean('hwy'))
->get();
$bars = Plot::of($byClass)
->aes(x: 'hwy', y: \Phiz\field('class', sort: '-x'), fill: \Phiz\calc('datum.class === '.json_encode($selected ?? ''), 'selected', 'nominal'))
->col()
->scaleFill(domain: [false, true], range: ['#9ECAE1', '#08519C'], guide: false)
->tooltip(['class', 'cars', 'hwy', 'cty'])
->labs(title: 'Mean highway mpg by class', x: 'Highway mpg', y: null)
->size(320, 220);
$scatter = Plot::of($subset)
->aes(x: 'displ', y: 'hwy', color: 'class')
->point(alpha: 0.7, size: 40)
->smooth(aes(x: 'displ', y: 'hwy'), method: 'lm', color: '#333333', inheritAes: false)
->tooltip(['manufacturer', 'model', 'year', 'displ', 'hwy'])
->theme(legendPosition: 'bottom')
->labs(title: 'Displacement and highway mpg', x: 'Displacement (L)', y: 'Highway mpg', color: null)
->size(320, 220);
$trend = Plot::of($byManufacturer)
->aes(x: 'year:O', y: 'hwy', group: 'manufacturer')
->line(color: '#BBBBBB')
->point(color: '#666666', size: 20)
->tooltip(['manufacturer', 'year', 'hwy'])
->labs(title: 'Manufacturers, 1999 to 2008', x: null, y: 'Mean highway mpg')
->size(200, 220);
return ['summary' => $byClass, 'bars' => $bars, 'scatter' => $scatter, 'trend' => $trend];
}With the drive filter on front-wheel drive, the year on 2008, and the compact class selected, the charts look like this:
use Phibble\DataFrame;
use Phiz\Compose;
use Phiz\Datasets;
require_once __DIR__.'/../../examples/dashboard.php';
// The dashboard with drive = 'f', year = 2008, and the compact class selected.
$charts = fuel_dashboard(DataFrame::fromRows(Datasets::mpg()), drive: 'f', year: 2008, selected: 'compact');
$plot = Compose::beside($charts['bars'], $charts['scatter'], $charts['trend']);The Livewire component
The component keeps the filters as public properties. render() passes them to fuel_dashboard(). Livewire calls render() after every update, so a new selection produces new specifications, and the phiz loader re-embeds each chart whose specification changed.
namespace App\Livewire;
use Livewire\Attributes\Url;
use Livewire\Component;
use Phibble\DataFrame;
use Phiz\Datasets;
class FuelDashboard extends Component
{
#[Url]
public ?string $drive = null;
#[Url]
public ?int $year = null;
public ?string $selected = null;
public function select(string $class): void
{
$this->selected = $this->selected === $class ? null : $class;
}
public function render()
{
// In an application this would be DataFrame::fromQuery(Car::query()) or a cached table.
$cars = DataFrame::fromRows(Datasets::mpg());
return view('livewire.fuel-dashboard', fuel_dashboard($cars, $this->drive, $this->year, $this->selected));
}
}The view puts the filters and the three charts side by side. Each <x-phiz> has a wire:key, so Livewire matches each chart to its element across renders.
<div class="space-y-4">
<div class="flex gap-4">
<select wire:model.live="drive">
<option value="">All drives</option>
<option value="f">Front</option>
<option value="r">Rear</option>
<option value="4">4WD</option>
</select>
<select wire:model.live="year">
<option value="">Both years</option>
<option value="1999">1999</option>
<option value="2008">2008</option>
</select>
</div>
<div class="grid grid-cols-3 gap-4">
<x-phiz :plot="$bars" wire:key="bars" />
<x-phiz :plot="$scatter" wire:key="scatter" />
<x-phiz :plot="$trend" wire:key="trend" />
</div>
<ul class="flex gap-2 text-sm">
@foreach ($summary->toRows() as $row)
<li><button wire:click="select('{{ $row['class'] }}')">{{ $row['class'] }} ({{ $row['cars'] }})</button></li>
@endforeach
</ul>
</div>
A click on a chart can also drive the component. The loader dispatches a phiz:rendered event with the Vega view, and a small Alpine handler can forward selections with $wire:
<div x-on:phiz:rendered="$event.detail.view.addEventListener('click', (e, item) => item?.datum?.class && $wire.select(item.datum.class))">
<x-phiz :plot="$bars" wire:key="bars" />
</div>
Linking in the browser
When the data is small enough to send to the page, a composition links charts without a request to the server. A brush on the scatterplot filters the bar chart, and Vega-Lite recomputes the means in the browser.
use Phiz\Compose;
use Phiz\Datasets;
use Phiz\Plot;
$mpg = Datasets::mpg();
// Linked in the browser: brushing the scatterplot filters the bars, with no request to the server.
$plot = Compose::beside(
Plot::of($mpg)->aes(x: 'displ', y: 'hwy', color: 'drv')->point(alpha: 0.7)->brush(encodings: ['x', 'y'])->size(320, 240),
Plot::of($mpg)->aes(x: 'mean(hwy)', y: \Phiz\field('class', sort: '-x'))->col(filter: ['param' => 'brush'], fill: '#4C78A8')->labs(x: 'Mean highway mpg', y: null)->size(260, 240),
);Choosing where to compute
The two approaches suit different data:
- Server-side summaries send only the summarized rows to the page. They suit large tables, database queries, and statistics that need PHP, such as model fits. Each interaction costs a Livewire round trip.
- Browser-side linking sends the raw rows once and responds to every interaction immediately. It suits up to a few thousand rows. Large data covers what to do beyond that.
Both approaches can share a page. For example, a Livewire filter can choose the rows, and a brush can explore them in the browser.
From phpstats/phiz@bc7ed3e.