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
| type | Descrição |
|---|---|
bar | Padrão para comparação de categorias. |
line | Tendência ao longo do tempo. |
pie | Proporção/participação. |
donut | Pizza com furo central. |
rose | Variação radial do pie. |
funnel | Etapas progressivas (vendas, conversão). |
treemap | Áreas hierárquicas proporcionais. |
radar | Categorias viram eixos radiais; cada série é um polígono. Perfis multi-dimensão. |
mixed | Colunas + 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" />
: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
| Prop | Tipo | Descrição |
|---|---|---|
type | string | bar, line, pie, donut, rose, funnel, treemap, radar, mixed (default bar). Cada tipo tem tag-alias: <mad-radar-chart> ≡ <mad-db-chart type="radar">. |
model | string | Classe Eloquent para auto-query. |
database | string | Conexão (default MAIN_DATABASE). |
group-by | string | Campo de agrupamento (eixo X / fatias). |
field | string | Campo agregado quando total é sum/avg/min/max. |
total | string | count, sum, avg, min, max. |
query | Builder | Eloquent Builder pronto — substitui model quando precisa de joins/scopes. |
filters | array | [['campo','op','val'], ...]. |
data | array | Dados manuais (dispensa model/query). |
title / subtitle | string | Título do card. |
height | int | Altura em px (default 300). |
no-panel | bool | Sem card wrapper — só o canvas. |
legend | bool | Mostra legenda. |
legend-transformer | callable | Resolve 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. |
format | string | Ver tabela de formatos abaixo (ex: currency:R$, numeric:2, abbreviate). |
colors | array | Paleta custom. |
horizontal | bool | Barras horizontais. |
stacked | bool | Barras empilhadas. |
area | bool | Line com área preenchida. |
percentage | bool | Mostra percentual (pie/donut). |
abbreviate | bool | Abrevia números grandes (1,5K / 2,3M). |
Formatos de valor (format)
| Formato | Resultado |
|---|---|
integer | Inteiro com separador de milhar. |
numeric:DEC | Decimal 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-long | Datas — 15/04/2026, 15/04, 15 de abril de 2026. |
month-year / month-year-short | Abril/2026 / Abr/26. |
quarter-year | Q2/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
:colorsposicionais funcionam. legend-transformerformata os NOMES dos indicators;sub-legend-transformerformata os nomes das séries (2ª dimensão).
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')
| Prop | Tipo / default | Descrição |
|---|---|---|
line-series | array|string (CSV) · [] | Modo A: nomes (case-insensitive) ou índices int das séries que viram linha. Builder: seriesAsLine(). |
line-total | string · '' | Modo B: count, sum, avg, min, max. Builder: lineMetric(). |
line-field | string · '' | Modo B: campo da 2ª agregação. Vazio só é válido com line-total="count" (vira count(*)); count(campo) conta não-nulos. |
line-label | string · '' | Modo B: nome da série de linha (default "{total}({field})" — sempre defina um label legível). |
line-secondary-axis | bool · false | Modos 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). stackedempilha só as colunas — a linha fica fora do stack (caso clássico: colunas empilhadas + linha de meta).horizontalestacked="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 pelodataIndex. - 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 propline-*promove o chart amixed. Prefira declarartype="mixed"explícito.
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" />
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>
: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
| aggregation | Descrição |
|---|---|
sum | Soma (default). |
avg | Média. |
count | Contagem. |
min | Mínimo. |
max | Máximo. |
Formatos de valor
| format | Descrição |
|---|---|
number | Número decimal. |
currency | Moeda — usa currency="BRL" (ou format="currency:BRL"). |
percent | Porcentagem. |
string | Texto, sem formatação numérica. |
Outras props de <mad-pivot-table>
| Prop | Tipo | Descrição |
|---|---|---|
width | string | Largura (default 100%). |
no-panel | bool | Sem card wrapper. |
no-data-label | string | Texto quando não há dados (default "Sem dados para exibir"). |
grand-totals | bool | Total geral (default true). |
row-totals / column-totals | bool | Totais por linha/coluna (default true). |
virtual-scrolling | bool | Default true — necessário pra datasets grandes. |
rows-per-page | int | Default 50. |
language / locale | string | Default pt-BR. |
theme | string | default · dark · compact. |
compact | bool | Layout mais denso. |
presets | array | Views 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:
| periodType | Janela anterior |
|---|---|
month-year | Mês anterior (rolando o ano quando o mês atual é janeiro). |
date-range | Janela imediatamente anterior, do mesmo tamanho em dias. |
preset | Unidade 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(...)" />