phiz

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() and summarize(). 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.