Docs›Avançado›Charts e Pivot Table
Avançado

Charts e Pivot Table

ECharts customization, pivot multi-valor, transformers.

Componentes do MAD para visualização analítica: <mad-db-chart> (gráficos ECharts com auto-query Eloquent), <mad-chart> (gráficos ECharts via builder PHP fluente, pra lógica custom pesada), <mad-pivot-table> (tabela dinâmica/cross-tab estilo Excel) e <mad-dashboard-metric-compare> (KPI com comparação automática vs período anterior).

mad-db-chart

Gráfico ECharts gerado a partir de uma query Eloquent. Zero JavaScript no controller.

Tipos de chart

typeDescrição
barPadrão para comparação de categorias.
lineTendência ao longo do tempo.
pieProporção/participação.
donutPizza com furo central.
roseVariação radial do pie.
funnelEtapas progressivas (vendas, conversão).
treemapÁreas hierárquicas proporcionais.
radarCategorias viram eixos radiais; cada série é um polígono. Perfis multi-dimensão.
mixedColunas + linha(s) no mesmo chart, com eixo Y secundário opcional.

Bar — pedidos por status

<mad-db-chart type="bar" model="PedidoVenda" database="business"
    group-by="status" total="count"
    title="Pedidos por Status" height="300" />

Line — vendas por mês

<mad-db-chart type="line" model="PedidoVenda"
    group-by="mes" field="valor_total" total="sum"
    title="Vendas por Mês" format="currency:R$" area />

Pie/Donut — clientes por estado

<mad-db-chart type="donut" model="Cliente"
    group-by="estado" total="count"
    title="Clientes por Estado" legend percentage />

Com filtros ou query custom

{{-- Filtros inline (array-DSL — campo, operador, valor) --}}
<mad-db-chart type="bar" model="PedidoVenda"
    :filters="[['ativo', '=', '1']]"
    group-by="categoria" total="count" />

{{-- Query Eloquent montada fora do componente — joins, scopes, subqueries --}}
@php
    $query = PedidoVenda::query()
        ->whereYear('data', '=', date('Y'))
        ->whereHas('cliente', fn ($q) => $q->where('ativo', '=', '1'));
@endphp
<mad-db-chart type="line" :query="$query"
    group-by="mes" field="valor_total" total="sum" />
Não existe prop :criteria

mad-db-chart aceita :filters (array de tuplas ['campo','op','val']) ou :query (um Illuminate\Database\Eloquent\Builder pronto). Objetos de critério do legado não existem mais — monte a query com Eloquent puro.

Dados manuais (sem model)

<mad-db-chart type="pie"
    :data="['Eletronicos' => 4500, 'Roupas' => 2100, 'Alimentos' => 3200]"
    title="Distribuição" legend />

Props completas

PropTipoDescrição
typestringbar, line, pie, donut, rose, funnel, treemap, radar, mixed (default bar). Cada tipo tem tag-alias: <mad-radar-chart> ≡ <mad-db-chart type="radar">.
modelstringClasse Eloquent para auto-query.
databasestringConexão (default MAIN_DATABASE).
group-bystringCampo de agrupamento (eixo X / fatias).
fieldstringCampo agregado quando total é sum/avg/min/max.
totalstringcount, sum, avg, min, max.
queryBuilderEloquent Builder pronto — substitui model quando precisa de joins/scopes.
filtersarray[['campo','op','val'], ...].
dataarrayDados manuais (dispensa model/query).
title / subtitlestringTítulo do card.
heightintAltura em px (default 300).
no-panelboolSem card wrapper — só o canvas.
legendboolMostra legenda.
legend-transformercallableResolve id → label na legenda. Aceita qualquer callable (closure ou Transformers::nome(...)). Roda 1× por valor de legenda — evite lookup no banco por valor (N+1); prefira um mapa pré-carregado.
formatstringVer tabela de formatos abaixo (ex: currency:R$, numeric:2, abbreviate).
colorsarrayPaleta custom.
horizontalboolBarras horizontais.
stackedboolBarras empilhadas.
areaboolLine com área preenchida.
percentageboolMostra percentual (pie/donut).
abbreviateboolAbrevia números grandes (1,5K / 2,3M).

Formatos de valor (format)

FormatoResultado
integerInteiro com separador de milhar.
numeric:DECDecimal pt-BR — ex: numeric:2 → 1.234,56.
currency:PREFIX[:DEC]Moeda — ex: currency:R$ → R$ 1.234,56 (alias money:).
percent[:DEC]Percentual — ex: percent:1 → 12,3%.
abbreviate[:DEC]Abreviado — 1,5K / 2,3M / 1,2B.
date / date-short / date-longDatas — 15/04/2026, 15/04, 15 de abril de 2026.
month-year / month-year-shortAbril/2026 / Abr/26.
quarter-yearQ2/2026.

Radar — perfis multi-dimensão

Em type="radar" as categorias (1ª dimensão do group-by) viram os eixos radiais (indicators) e cada valor da 2ª dimensão vira um polígono. Sem 2ª dimensão, é um polígono só.

{{-- 1 dimensão → 1 polígono, categorias = eixos --}}
<mad-db-chart type="radar" model="IamUserGroup" database="iam"
    group-by="mad_iam_user_group.group_id" total="count"
    :title="__('admin.users_by_group')" height="320"
    :legend-transformer="$groupNameResolver" />

{{-- 2 dimensões → N polígonos (1 por valor da 2ª dim), com legenda --}}
<mad-db-chart type="radar"
    :group-by="['mes', 'canal']" total="sum" field="valor"
    :query="$vendasQuery" legend
    title="Vendas por mês × canal" height="360" />

{{-- alias de tag --}}
<mad-radar-chart model="Venda" group-by="regiao" total="sum" field="valor" />
use Mad\Chart\MadChart;

$chart = MadChart::radar('vendas')
    ->fromModel('Venda')          // ou ->fromQuery($builder)
    ->groupBy(['mes', 'canal'])   // 1 ou 2 dimensões
    ->sum('valor')                // sum|count|avg|min|max
    ->legend()
    ->title('Vendas por canal');

// dados manuais (1 polígono)
$chart = MadChart::radar('perfil')
    ->data(['Frontend' => 8, 'Backend' => 6, 'DevOps' => 4]);

Regras automáticas da engine (não precisa configurar):

  • Max único global em todos os indicators — teto arredondado com folga de 5% (30 → 40, 730 → 800), mantendo os polígonos comparáveis. Tudo zero → max 1.
  • Negativos são clampados em 0 no polígono (ECharts radar não tem eixo negativo); com transformer, o tooltip ainda mostra o valor bruto.
  • Combinação categoria×série ausente vira 0 — os vetores ficam sempre alinhados aos indicators.
  • Ordem das séries é alfabética, então :colors posicionais funcionam.
  • legend-transformer formata os NOMES dos indicators; sub-legend-transformer formata os nomes das séries (2ª dimensão).
Limitações do radar

Sem drill-through: filter-prop/filter-mode são ignorados (o clique devolve o polígono inteiro, não uma categoria); click-action custom continua valendo, com essa semântica. horizontal, stacked, area e smooth não se aplicam. Use ≥ 3 categorias — com 1-2 o polígono degenera em ponto/linha. legend só tem efeito com group-by de 2 dimensões.

Mixed — colunas + linha

type="mixed" tem dois modos mutuamente exclusivos (configurar os dois lança InvalidArgumentException).

Modo A — séries do group-by viram linha

As séries já existem nos dados (2ª dimensão do group-by) e algumas devem virar linha. Exige group-by de 2 dimensões.

{{-- 'Meta' (valor da 2ª dimensão) vira linha sobre as colunas --}}
<mad-db-chart type="mixed"
    :group-by="['mes', 'canal']" total="sum" field="valor"
    :query="$vendasQuery"
    line-series="Meta" legend stacked
    title="Vendas × Meta" height="340" />

{{-- várias séries + eixo Y secundário --}}
<mad-db-chart type="mixed"
    :group-by="['mes', 'canal']" total="sum" field="valor"
    :line-series="['Meta', 'Média']" line-secondary-axis legend />
MadChart::mixed('vendas')
    ->fromQuery($query)
    ->groupBy(['mes', 'canal'])->sum('valor')
    ->stacked()                                   // empilha SÓ as colunas
    ->seriesAsLine(['Meta'], secondaryAxis: true);
    // aceita CSV: ->seriesAsLine('Meta,Média')
    // aceita índice int (ordem alfabética das séries): ->seriesAsLine([1])

O match da série é por nome case-insensitive (contra o nome final, pós sub-legend-transformer) OU por índice int (posição na ordem alfabética). Nome sem match é no-op silencioso — o chart renderiza como bar puro.

Modo B — 2ª métrica agregada como linha

Colunas = uma agregação, linha = OUTRA agregação dos mesmos registros. A 2ª agregação roda no mesmo SELECT (uma query só, alias line_total). Exige group-by de 1 dimensão.

{{-- colunas = logins/dia; linha = count de logouts (não-nulos) --}}
<mad-db-chart type="mixed"
    group-by="date(mad_log_access.login_time)" total="count"
    :query="$accessQuery"
    line-total="count" line-field="mad_log_access.logout_time"
    line-label="Logouts" legend
    title="Acessos" height="320" />

{{-- colunas = soma; linha = média (escalas diferentes → eixo secundário) --}}
<mad-db-chart type="mixed"
    group-by="mes" total="sum" field="valor" model="Venda"
    line-total="avg" line-field="ticket" line-label="Ticket médio"
    line-secondary-axis legend />
MadChart::mixed('acessos')
    ->fromQuery($query)
    ->groupBy('mes')->sum('valor')
    ->lineMetric('avg', 'ticket', 'Ticket médio', secondaryAxis: true);
    // count sem field → count(*):  ->lineMetric('count')
PropTipo / defaultDescrição
line-seriesarray|string (CSV) · []Modo A: nomes (case-insensitive) ou índices int das séries que viram linha. Builder: seriesAsLine().
line-totalstring · ''Modo B: count, sum, avg, min, max. Builder: lineMetric().
line-fieldstring · ''Modo B: campo da 2ª agregação. Vazio só é válido com line-total="count" (vira count(*)); count(campo) conta não-nulos.
line-labelstring · ''Modo B: nome da série de linha (default "{total}({field})" — sempre defina um label legível).
line-secondary-axisbool · falseModos A e B: linha no eixo Y da direita (yAxis vira lista [esquerdo, direito]).

Comportamento comum aos dois modos:

  • A linha é smooth, símbolo circle, z:3 — sempre na FRENTE das colunas; cor = próximo stroke da paleta (ou posicional do :colors).
  • stacked empilha só as colunas — a linha fica fora do stack (caso clássico: colunas empilhadas + linha de meta).
  • horizontal e stacked="internal" são ignorados (mixed é sempre vertical).
  • Click-to-filter funciona normal (filter-prop/filter-mode): clique na coluna ou no ponto da linha resolve a categoria pelo dataIndex.
  • No modo B a legenda vai pro topo (o default bottom do ECharts v6 colidiria com os labels do eixo X).
  • Auto-promoção: type="bar" (ou omitido) + qualquer prop line-* promove o chart a mixed. Prefira declarar type="mixed" explícito.
Erros que o backend lança

line-series junto com line-total → "use line-series OU line-total/line-field, não os dois". line-total com group-by de 2 dimensões → "line-metric exige group-by de 1 dimensão; para 2 dimensões use line-series". line-field vazio com line-total ≠ count também é erro. Com line-secondary-axis, quem sobrescreve yAxis via :options precisa tratá-lo como LISTA.

mad-chart — builder PHP (lógica custom)

Quando o agrupamento/agregação não cabe no array-DSL do mad-db-chart (múltiplas séries, transformação pesada, dados de várias tabelas), monte a configuração com o builder fluente Mad\Chart\MadChart no controller e injete via :config:

use Mad\Chart\MadChart;

// Com ORM — agrega via Eloquent
$chart = MadChart::bar('vendas_mes')
    ->fromModel('PedidoVenda')
    ->groupBy(['mes'])
    ->sum('valor_total')
    ->title('Vendas por Mês')
    ->currency()
    ->height(350);

// Com dados manuais
$chart2 = MadChart::donut('categorias')
    ->data(['Eletrônicos' => 4500, 'Roupas' => 2100])
    ->title('Distribuição')
    ->currency();
<mad-chart :config="$chart" />
mad-db-chart primeiro

Use <mad-chart> só quando <mad-db-chart> (group-by/total/filters declarativos) não resolve. Construir a option do ECharts manualmente quando o auto-query já cobre o caso é duplicação desnecessária.

mad-pivot-table

Tabela dinâmica com agrupamento por linhas/colunas, agregação e filtros interativos. O componente carrega as linhas via Eloquent/Query Builder no servidor e entrega os dados crus a um grid de pivot embarcado (renderizado em iframe) — o agrupamento, a paginação e o drag-and-drop de campos acontecem no cliente, dentro desse grid.

Estrutura básica

<mad-pivot-table model="PedidoVenda" database="business"
    title="Vendas por Região" height="500">

    <mad-pivot-row field="regiao" label="Região" />
    <mad-pivot-value field="valor" label="Valor"
        aggregation="sum" format="currency" currency="BRL" />
</mad-pivot-table>

Cross-tab (linha x coluna)

<mad-pivot-table model="PedidoVenda"
    title="Vendas: Região x Categoria">

    <mad-pivot-row field="regiao" />
    <mad-pivot-col field="categoria" />
    <mad-pivot-value field="valor" aggregation="sum" format="currency" />
</mad-pivot-table>

Multi-nível com subtotais

<mad-pivot-table model="PedidoVenda" subtotals subtotals-position="below">

    <mad-pivot-row field="regiao" />
    <mad-pivot-row field="estado" />
    <mad-pivot-value field="valor"  aggregation="sum" format="currency" />
    <mad-pivot-value field="lucro"  aggregation="sum" format="currency" />
</mad-pivot-table>

Múltiplas agregações do mesmo campo

<mad-pivot-table model="PedidoVenda">
    <mad-pivot-row field="categoria" />
    <mad-pivot-value field="valor" label="Total Vendas"
        aggregation="sum" format="currency" />
    <mad-pivot-value field="valor" label="Ticket Médio"
        aggregation="avg" format="currency" />
    <mad-pivot-value field="id" label="Núm. Vendas"
        aggregation="count" format="number" />
</mad-pivot-table>

Com filtros interativos

<mad-pivot-table model="PedidoVenda">
    <mad-pivot-row field="vendedor" />
    <mad-pivot-col field="canal" />
    <mad-pivot-value field="valor" aggregation="sum" format="currency" />

    {{-- Filtros que o usuário pode escolher na UI do grid --}}
    <mad-pivot-filter field="regiao" label="Região" />
    <mad-pivot-filter field="categoria" label="Categoria" />
</mad-pivot-table>

Field list — drag and drop

<mad-pivot-table model="PedidoVenda" field-list field-list-layout="horizontal">
    <mad-pivot-row field="regiao" />
    <mad-pivot-row field="categoria" />
    <mad-pivot-col field="mes" />
    <mad-pivot-value field="valor" aggregation="sum" format="currency" />
    <mad-pivot-filter field="vendedor" />
</mad-pivot-table>

O usuário pode arrastar campos entre áreas (rows, columns, values, filters) interativamente.

Filtros fixos e joins server-side

<mad-pivot-table model="PedidoVenda"
    :filters="[['ativo', '=', '1']]"
    :joins="['cliente' => ['cliente_id', '=', 'cliente.id']]">
    <mad-pivot-row field="regiao" />
    <mad-pivot-value field="valor" aggregation="sum" format="currency" />
</mad-pivot-table>
Shape de :joins

Chave = nome da tabela no banco (não o model). Valor = tupla [coluna_local, operador?, coluna_estrangeira] — operador é opcional (default =). Qualifique a coluna estrangeira com o nome da tabela (cliente.id); sem isso o componente prefixa com a tabela base, o que geralmente não é o que você quer.

Agregações disponíveis

aggregationDescrição
sumSoma (default).
avgMédia.
countContagem.
minMínimo.
maxMáximo.

Formatos de valor

formatDescrição
numberNúmero decimal.
currencyMoeda — usa currency="BRL" (ou format="currency:BRL").
percentPorcentagem.
stringTexto, sem formatação numérica.

Outras props de <mad-pivot-table>

PropTipoDescrição
widthstringLargura (default 100%).
no-panelboolSem card wrapper.
no-data-labelstringTexto quando não há dados (default "Sem dados para exibir").
grand-totalsboolTotal geral (default true).
row-totals / column-totalsboolTotais por linha/coluna (default true).
virtual-scrollingboolDefault true — necessário pra datasets grandes.
rows-per-pageintDefault 50.
language / localestringDefault pt-BR.
themestringdefault · dark · compact.
compactboolLayout mais denso.
presetsarrayViews pré-configuradas (alterna conjuntos de rows/cols/values salvos).

Comparação com período anterior — mad-dashboard-metric-compare

Mad\Dashboard\MadDashboard (base class dos dashboards) calcula automaticamente a janela de comparação a partir do filtro de período ativo (month-year, date-range ou preset) via comparePeriodQuery(), e o delta percentual via computeDelta(). O wrapper <mad-dashboard-metric-compare> junta os dois automaticamente num KPI com trend:

@php
    $atual    = $this->baseQuery('PedidoVenda'); // período + unit + auto-filters atuais
    $anterior = $this->comparePeriodQuery('PedidoVenda');
@endphp

<mad-dashboard-metric-compare
    :current-query="$atual" :compare-query="$anterior"
    field="valor_total" total="sum"
    label="Receita" icon="circle-dollar-sign" variant="success"
    format="money:R$" compare-label="vs período anterior" />

comparePeriodQuery() desloca a janela conforme o periodType do dashboard:

periodTypeJanela anterior
month-yearMês anterior (rolando o ano quando o mês atual é janeiro).
date-rangeJanela imediatamente anterior, do mesmo tamanho em dias.
presetUnidade de calendário anterior cheia pra mes/trimestre/ano; janela móvel do mesmo tamanho pra presets relativos (hoje, 7d etc).

O trend resultante (up/down/flat) e o label (+12,3%, -5,7%, +∞ quando o período anterior era zero) seguem a mesma semântica usada no legado — só a fonte de dados mudou pra Eloquent Builder puro.

Transformer de legenda/valor (id → label)

O legend-transformer aceita qualquer callable — closure ou first-class callable. O motor de chart o invoca call_user_func($fn, $valor, $item, $data) uma vez por valor distinto de legenda (ver EChart::renderLegend). A regra prática NÃO é "closure é proibida" — é não fazer query no banco por valor, que vira N+1. Três formas corretas:

1. Closure sobre um mapa pré-carregado (1 query só, ideal p/ inline):

@php
    // 1 query — carrega o de-para uma vez, antes do render
    $statusMap = StatusPedido::pluck('nome', 'id')->all();
@endphp
<mad-db-chart type="donut" model="PedidoVenda"
    group-by="status_id" total="count" title="Por Status" legend
    :legend-transformer="fn(\$id) => \$statusMap[\$id] ?? \$id" />

2. First-class callable de um Transformers global que já faz batch internamente:

<mad-db-chart type="donut" model="PedidoVenda"
    group-by="status_id" total="count" title="Por Status" legend
    :legend-transformer="Transformers::statusPedidoNome(...)" />

3. Sem banco nenhum — só formatação de label: use legend-format (data/número/moeda) em vez de um transformer.

Anti-padrão (N+1): :legend-transformer="fn(\$v) => StatusPedido::find(\$v)?->nome" — um find() por fatia da legenda. Tecnicamente funciona, mas dispara uma query por valor. Feche sobre um mapa (forma 1) em vez disso.

Combinação chart + pivot

<mad-form-grid :cols="2">
    <mad-db-chart type="bar" model="PedidoVenda"
        group-by="mes" field="valor_total" total="sum"
        title="Total por Mês" format="currency:R$" />

    <mad-db-chart type="donut" model="PedidoVenda"
        group-by="categoria" total="count"
        title="Por Categoria" legend percentage />
</mad-form-grid>

<mad-pivot-table model="PedidoVenda" title="Detalhamento">
    <mad-pivot-row field="regiao" />
    <mad-pivot-col field="mes" />
    <mad-pivot-value field="valor_total" aggregation="sum" format="currency" />
</mad-pivot-table>

NUNCA fazer

{{-- ERRADO: montar gráfico ECharts manualmente --}}
<div id="meu-chart" style="height:300px;"></div>
<script>
    var c = echarts.init(document.getElementById('meu-chart'));
    c.setOption({ ... });
</script>

{{-- CERTO: declarativo no Blade --}}
<mad-db-chart type="bar" model="PedidoVenda"
    group-by="mes" field="valor_total" total="sum" />

{{-- ERRADO: critério pré-montado do legado --}}
@php
    $crit = new \TCriteria();
    $crit->add(new \TFilter('ano', '=', date('Y')));
@endphp
<mad-db-chart type="line" model="PedidoVenda" :criteria="$crit" group-by="mes" />

{{-- CERTO: array-DSL ou Eloquent Builder --}}
<mad-db-chart type="line" model="PedidoVenda"
    :filters="[['ano', '=', date('Y')]]" group-by="mes" />

{{-- ERRADO: montar pivot com @foreach aninhado --}}
@foreach($regioes as $r)
    <tr>
        @foreach($categorias as $c)
            <td>{{ $totais[$r][$c] ?? 0 }}</td>
        @endforeach
    </tr>
@endforeach

{{-- CERTO --}}
<mad-pivot-table model="PedidoVenda">
    <mad-pivot-row field="regiao" />
    <mad-pivot-col field="categoria" />
    <mad-pivot-value field="valor" aggregation="sum" format="currency" />
</mad-pivot-table>

{{-- ERRADO: query no banco por valor de legenda (N+1) --}}
<mad-db-chart type="donut" model="PedidoVenda" group-by="status_id"
    :legend-transformer="fn($v) => StatusPedido::find($v)?->nome" />

{{-- CERTO: closure sobre um mapa pré-carregado (1 query) --}}
@php $statusMap = StatusPedido::pluck('nome', 'id')->all(); @endphp
<mad-db-chart type="donut" model="PedidoVenda" group-by="status_id"
    :legend-transformer="fn($v) => $statusMap[$v] ?? $v" />

{{-- CERTO (alternativa): Transformers global que faz batch --}}
<mad-db-chart type="donut" model="PedidoVenda" group-by="status_id"
    :legend-transformer="Transformers::statusPedidoNome(...)" />

Próximos passos