Skip to content

Chart widget

A chart rendered with Chart.js.

ChartWidget::make('Pipeline value')
->type(ChartWidget::DOUGHNUT)
->money('EUR')
->permission('deals.view')
->data(static function (): array {
$rows = (new DealModel())->select('stage, COALESCE(SUM(amount), 0) AS value')->groupBy('stage')->findAll();
return [
'labels' => array_column($rows, 'stage'),
'datasets' => [['label' => 'Amount', 'data' => array_column($rows, 'value')]],
];
});
Method Default Description
make(string $label) – heading of the card, also the accessible name of the chart
type(string $type) ChartWidget::BAR BAR, LINE, DOUGHNUT, PIE; other values throw
data(Closure $data) – returns ['labels' => [...], 'datasets' => [['label' => ..., 'data' => [...]], ...]], evaluated when the widget is shown
description(string $text) – line below the heading
money(string $currency = 'EUR') – formats values on the axis and in tooltips as money (ISO code of three capital letters, otherwise it throws)
horizontal(bool $horizontal = true) false bars (or lines) along the y axis
stacked(bool $stacked = true) false stacks the datasets
height(int $height) 260 height in pixels, 120–800
permission(string $permission) – required permission
span(int $span) 6 width on large screens, 3–12
key(string $key) from the label stable key of the layout
// Line: requests per month of the last twelve months
ChartWidget::make('Requests per month')->type(ChartWidget::LINE)->key('requests-per-month')
->permission('purchase-requests.view')
->data(function (): array {
$rows = $this->resourceByClass(PurchaseRequestResource::class)->query()
->select("DATE_FORMAT(created_at, '%Y-%m') AS month, COUNT(*) AS requests", false)
->where('created_at >=', date('Y-m-01', strtotime('-11 months')))
->groupBy('month')->orderBy('month')->findAll();
return ['labels' => array_column($rows, 'month'), 'datasets' => [['label' => 'Requests', 'data' => array_column($rows, 'requests')]]];
}),
// Horizontal, stacked bars with two datasets
ChartWidget::make('Tasks per team')->horizontal()->stacked()->height(320)
->data(static fn (): array => [
'labels' => ['IT', 'HR', 'Facility'],
'datasets' => [['label' => 'Open', 'data' => [5, 2, 1]], ['label' => 'Done', 'data' => [12, 9, 4]]],
]),

Query through the resource ($panel->resourceByClass(...)->query()) so that tenant and scopeQuery() apply.

config() normalises what data() returns before it reaches the browser:

  • Labels become strings and are shown as text, never as HTML.
  • Values may be numeric strings, e.g. the result of SUM() in SQL; they are converted to numbers. Other values become null (a gap).
  • At most 12 datasets with 500 values each; the rest is cut off.
  • Bar, line: one colour per dataset. Doughnut, pie: one colour per slice. The legend is shown for round charts and for more than one dataset.
  • Numbers are formatted with the language of the page (<html lang>). When all values are integers, the axis uses whole steps.

config() is public, so a test can check the data without a browser:

$this->assertSame([4, 7, 2], $chart->config()['datasets'][0]['data']);
  • The configuration is a JSON data attribute of a <canvas> (data-anvil-chart); no script is rendered into the page, which keeps the Content Security Policy intact. The height is a CSS class (anvil-chart-h-<n>, rounded down to steps of 20 px), not a style attribute.
  • Chart.js is not part of anvil.js. It is a separate file (anvil-charts.js) that anvil.js loads with its integrity hash the first time a page contains a chart, also after htmx swaps. Pages without charts never load it.
  • Colours come from the theme (--anvil-primary and the Bootstrap colours) and follow light and dark mode; charts are redrawn when the user switches the theme.
  • Every chart also contains its data as a visually hidden table for screen readers.