Возможности сводных таблиц в Superset

С версии Superset 6.0 в метрики сводных таблиц можно подставлять произвольный HTML. Это открывает возможности, которые раньше казались недоступными — например, карточки KPI прямо в ячейках Pivot Table.
Я уже описывала различные фишки, которые можно реализовывать в сводных таблицах: здесь, здесь и здесь.
В данной статье продолжу раскрывать потенциал сводных таблиц.
Заказчик пришел с запросом вывести показатели в карточках и прислал пример, как должен выглядеть результат.
Выглядело это примерно так:

Что мы имеем: двумерная сетка - регион по строкам, карточки городов в колонках. Первое, что приходит в голову , — это использовать Handlebars.
Собрать матрицу в шаблоне можно, но тогда приходится вручную раскладывать плоский список через CSS grid: следить за шириной ряда и разбивкой по регионам. Работает, пока карточек немного и макет не меняется. А вот масштабировать такую раскладку — отдельная история. Я решила пойти другим путем и попробовать отрисовать такие карточки в сводной таблице. Сводная таблица — готовая матрица: ячейка «регион × город» существует сама по себе, и остаётся положить в неё карточку.
В строках — регион, внутри карточки — город и значения двух показателей. Вроде всё понятно, ничего сложного: объединить название города с метриками и вложить в одну ячейку — сложности нет. А вот как расставить карточки по ячейкам? Если добавить город в измерения, таблица выглядит совсем не так, как хотелось бы. В таблице пропуски, а нам так не надо.

Я уже думала, что ничего из этого не выйдет, но тут вдруг появилась идея отранжировать города внутри каждого региона и уже этот номер добавлять в столбцы.
И вот что получилось. Ура.

Главное решение было найдено, остальное дело за малым — собрать метрики в одной ячейке и придать красивый вид с помощью html и css.
Вот так выглядит сборка метрики для вывода данных в одной ячейке с применением html и css. Эта метрика будет прописана в SQL запросе, который представлен ниже.
CONCAT( '<div>',
'<span class = "city-name">', city ,'</span>',
'<div class = "card_metric">',
'<div>',
CASE
WHEN sale_qnty_delta >= 0
THEN '<span class = "point background-green"></span>'
ELSE '<span class = "point background-red"></span>'
END,
'<span class = "name_metric"> Продажи, шт: </span>',
CASE
WHEN sale_qnty_delta >= 0
THEN CONCAT('<span class="metric">',printf('%.0f',toFloat32(sale_qnty)), '</span>',
'<span class = "delta background-green" >▴', replaceOne(printf('%.2f%%',abs(sale_qnty_delta_perc) * 100), '.', ','),'</span>')
ELSE CONCAT('<span class="metric">',printf('%.0f',toFloat32(sale_qnty)), '</span>',
'<span class = "delta background-red" > ▾', replaceOne(printf('%.2f%%',abs(sale_qnty_delta_perc) * 100), '.', ','),'</span>')
END,
'</div>',
'<div>',
CASE
WHEN lost_share_delta <= 0
THEN '<span class = "point background-green"></span>'
ELSE '<span class = "point background-red"></span>'
END,
'<span class = "name_metric"> Потери, %: </span>',
CASE
WHEN lost_share_delta <= 0
THEN CONCAT('<span class="metric">',replaceOne(printf('%.2f%%',lost_share * 100), '.', ','), '</span>',
'<span class = "delta background-green">▾', replaceOne(printf('%.2f%%',abs(lost_share_delta) * 100), '.', ','),'</span>')
ELSE CONCAT('<span class="metric">',replaceOne(printf('%.2f%%',lost_share * 100), '.', ','), '</span>',
'<span class = "delta background-red"> ▴', replaceOne(printf('%.2f%%',abs(lost_share_delta) * 100), '.', ','),'</span>')
END,
'</div>',
'</div>
</div>'
) AS metric_print,На первый взгляд сборка показателя кажется сложной, но ниже выделила блоки из которых все складывается. В моем примере 2 показателя, но их может быть больше, в зависимости от вашей задачи.

В текущем кейсе показатели выводятся в разрезе недели и дельта считается к предыдущей неделе. Но можно подобно тому как было разобрано в статье, сделать динамически меняющийся период сравнения.
А вот и весь запрос к БД ClickHouse.
{% set table = "public.mytable" %}
{% set dims_days = "dims.days" %}
{% set whs_table = {
'Регион': "REGION",
'Город': "CITY"
}
%}
{# переменная с расчетом метрик индекс 0 - расчет метрики, 1 - как называем ее в скрипте #}
{% set measures_calc = {
'Продажи, шт': ["sum(SALE)/280","sale_qnty"],
'Потери, %': ["divide(toFloat32(sum(SALE_MARKDOWN)/130),sum(SALE)/280)","lost_share"]
}
%}
{% set partition_region_frmt = "OVER (partition by region, city ORDER BY period ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING)" %}
{% set measure_value %}
{% for value in measures_calc %}
{{ measures_calc.get(value)[0] }} as {{ measures_calc.get(value)[1] }},
anyOrNull({{ measures_calc.get(value)[1] }}) {{ partition_region_frmt }} as {{ measures_calc.get(value)[1] }}_prev,
{{ measures_calc.get(value)[1] }}-{{ measures_calc.get(value)[1] }}_prev as {{ measures_calc.get(value)[1] }}_delta,
divide(toFloat32({{ measures_calc.get(value)[1] }}),{{ measures_calc.get(value)[1] }}_prev) - 1 as {{ measures_calc.get(value)[1] }}_delta_perc,
{% endfor %}
{% endset %}
{% set measure_value_2 %}
{% for value in measures_calc %}
{{ measures_calc.get(value)[1] }},
{{ measures_calc.get(value)[1] }}_delta,
{{ measures_calc.get(value)[1] }}_delta_perc,
{% endfor %}
{% endset %}
/***************Запрос***********************/
{# определяем максимальную дату в источнике #}
/* Если вы знаете, что у вас стабильно есть данные например за вчера или за сегодня,
этот шаг можно пропустить */
WITH MAX_DATE_TABLE AS (
SELECT
MAX(DAY_ID) AS MAX_DATE
FROM {{ table }}
WHERE DAY_ID >= CURDATE()-14
)
/*берём 2 последних недели с данными*/
, DATE_TABLE AS (
SELECT
DAY_ID, WEEK_ID_2, WEEK_ISO_NUMBER
FROM {{ dims_days }}
WHERE WEEK_ID_2 IN (
SELECT
WEEK_ID_2
FROM {{ dims_days }}
WHERE 1=1
AND DAY_ID <= (SELECT MAX_DATE FROM MAX_DATE_TABLE)
GROUP BY WEEK_ID_2
ORDER BY WEEK_ID_2 DESC
LIMIT 2
)
)
SELECT
(SELECT CONCAT(max(WEEK_ISO_NUMBER),' нед к ', min(WEEK_ISO_NUMBER),' нед') FROM DATE_TABLE) as period_name,
region,
CONCAT( '<div>',
'<span class = "city-name">', city ,'</span>',
'<div class = "card_metric">',
'<div>',
CASE
WHEN sale_qnty_delta >= 0
THEN '<span class = "point background-green"></span>'
ELSE '<span class = "point background-red"></span>'
END,
'<span class = "name_metric"> Продажи, шт: </span>',
CASE
WHEN sale_qnty_delta >= 0
THEN CONCAT('<span class="metric">',printf('%.0f',toFloat32(sale_qnty)), '</span>',
'<span class = "delta background-green" >▴', replaceOne(printf('%.2f%%',abs(sale_qnty_delta_perc) * 100), '.', ','),'</span>')
ELSE CONCAT('<span class="metric">',printf('%.0f',toFloat32(sale_qnty)), '</span>',
'<span class = "delta background-red" > ▾', replaceOne(printf('%.2f%%',abs(sale_qnty_delta_perc) * 100), '.', ','),'</span>')
END,
'</div>',
'<div>',
CASE
WHEN lost_share_delta <= 0
THEN '<span class = "point background-green"></span>'
ELSE '<span class = "point background-red"></span>'
END,
'<span class = "name_metric"> Потери, %: </span>',
CASE
WHEN lost_share_delta <= 0
THEN CONCAT('<span class="metric">',replaceOne(printf('%.2f%%',lost_share * 100), '.', ','), '</span>',
'<span class = "delta background-green">▾', replaceOne(printf('%.2f%%',abs(lost_share_delta) * 100), '.', ','),'</span>')
ELSE CONCAT('<span class="metric">',replaceOne(printf('%.2f%%',lost_share * 100), '.', ','), '</span>',
'<span class = "delta background-red"> ▴', replaceOne(printf('%.2f%%',abs(lost_share_delta) * 100), '.', ','),'</span>')
END,
'</div>',
'</div>'
) AS metric_print,
ROW_NUMBER() OVER (PARTITION BY region ORDER BY city) as RN,
{{ measure_value_2 }}
1
FROM (
SELECT
WEEK_ID_2 AS period,
{{ whs_table['Регион'] }} AS region,
{{ whs_table['Город'] }} AS city,
{{ measure_value }}
1
FROM {{ table }}
WHERE 1=1
AND DAY_ID GLOBAL IN (SELECT DAY_ID FROM DATE_TABLE)
GROUP BY period, region, city
)
WHERE period = (SELECT max(WEEK_ID_2) FROM DATE_TABLE) /*оставляем только последнюю неделю*/
Собираем сводную.
В строки - регион, в колонки - период и номер, в метрики - показатель, который прописываем также с использованием html и CSS кода.

Цвет рамки карточки зависит от зон показателей:
Показатели | Рамка карточки |
|---|---|
Оба в зелёной зоне | Зелёная |
Один в красной зоне | Жёлтая |
Оба в красной зоне | Красная |
{% set measure_1 = "sum(sale_qnty_delta)" %}
{% set measure_2 = "sum(lost_share_delta)" %}
{% set measure_print = "any(metric_print)"%}
CASE
WHEN {{ measure_1 }} >= 0 and {{ measure_2 }} <= 0
THEN concat('<div class = "card_dc" style = "border-top: 4px solid #188A42 !important;">', {{ measure_print }}, '</div>')
WHEN {{ measure_1 }} >= 0 and {{ measure_2 }} > 0
THEN concat('<div class = "card_dc" style = "border-top: 4px solid #F1C232 !important">', {{ measure_print }}, '</div>')
WHEN {{ measure_1 }} < 0 and {{ measure_2 }} <= 0
THEN concat('<div class = "card_dc" style = "border-top: 4px solid #F1C232 !important">', {{ measure_print }}, '</div>')
WHEN {{ measure_1 }} < 0 and {{ measure_2 }} > 0
THEN concat('<div class = "card_dc" style = "border-top: 4px solid #D93025 !important;">',{{ measure_print }}, '</div>')
ELSE {{ measure_print }}
ENDОстается для всех присвоенных классов прописать CSS код в настройках дашборда.
CSS код приводится для чарта с названием "demo. region_card", не забудь поменять на свой, либо совсем убрать, тогда код применится ко всем сводным:
.city-name {
font-size: 14px;
font-weight: 600;
color: #4A4A4A;
}
.point {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
} /*общие параметры маркера*/
.point.background-red {
background-color: #D93025;
} /*красный маркер перед метрикой*/
.point.background-green {
background-color: #188A42;
} /*зеленый маркер перед метрикой*/
.metric {
padding: 1px 6px;
font-size: 14px !important;
font-weight: 600;
color: black;
}/*параметры метрики*/
.delta {
display: inline-block;
font-size: 12px !important;
border-radius: 10px;
padding: 1px 10px;
white-space: nowrap;
min-width: 70px;
}/*параметры дельты*/
.delta.background-red {
color: #D93025;
background-color: rgba(217, 48, 37, 0.09) !important;
}
.delta.background-green {
color: #188A42;
background-color: rgba(24, 138, 66, 0.10);
}
.card_dc {
border-radius: 10px;
border: 2px solid #dbdbdb !important ;
padding: 10px;
margin: 5px 0px;
box-shadow: 0 1px 2px rgba(16,24,40,.05), 0 8px 24px rgba(16,24,40,.07)
}
.card_metric > div {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
} /*прижимаем значения к правому и левому краю */
.name_metric {
display: inline-block;
color: grey;
text-align: left !important;
}
div[data-test-chart-name="demo. region_card"] .pivot_table_v_2 table.pvtTable {
width:65% !important;
background-color: white;
} /*задаем динамическую ширину сводной таблицы*/
div[data-test-chart-name="demo. region_card"] .pivot_table_v_2 table.pvtTable tr td.pvtVal {
text-wrap: nowrap!important; /*убираем перенос*/
text-align: justify !important;
} /*параметры для значений сводных таблиц*/
div[data-test-chart-name="demo. region_card"] .pivot_table_v_2 table.pvtTable,
div[data-test-chart-name="demo. region_card"] .pivot_table_v_2 table.pvtTable td ,
div[data-test-chart-name="demo. region_card"] .pivot_table_v_2 table.pvtTable th {
border: none !important;
} /*убираем все границы*/
div[data-test-chart-name="demo. region_card"] .pivot_table_v_2 table.pvtTable tr th.pvtColLabel {
font-size: 0px !important;
} /*убираем заголовок сверху*/
div[data-test-chart-name="demo. region_card"] .pivot_table_v_2 table.pvtTable tr:nth-of-type(2) th.pvtColLabel {
font-size: 16px !important;
font-weight: 600;
} /*возвращаем заголовок с номерами недель*/
div[data-test-chart-name="demo. region_card"] .pivot_table_v_2 table.pvtTable tr th.pvtRowLabel {
font-size: 16px !important;
font-weight: 600;
text-wrap: wrap !important;
padding: 0px;
vertical-align: middle;
} /*название регионов*/
div[data-test-chart-name="demo. region_card"] .pivot_table_v_2 table.pvtTable tr th.pvtAxisLabel{
font-size: 0px;
border-color: white;
width: 0%;
padding: 0px !important;
} /*убираем названия измерений слово metric и уменьшаем ширину*/
.header-title {
font-size: 0px;
padding: 0px;
} /*убираем заголовки у всех чартов*/
И вот что у нас получается после всех манипуляций. Красота!

Уже смотрится хорошо, но хочется дополнительно выделить карточки, где оба показателя в красной зоне.
Добавим градиент для карточек с красной рамкой
{% set measure_1 = "sum(sale_qnty_delta)" %}
{% set measure_2 = "sum(lost_share_delta)" %}
{% set measure_print = "any(metric_print)"%}
CASE
WHEN {{ measure_1 }} > 0 and {{ measure_2 }} <= 0
THEN concat('<div class = "card_dc" style = "border-top: 4px solid #188A42 !important;">', {{ measure_print }}, '</div>')
WHEN {{ measure_1 }} > 0 and {{ measure_2 }} > 0
THEN concat('<div class = "card_dc" style = "border-top: 4px solid #F1C232 !important">', {{ measure_print }}, '</div>')
WHEN {{ measure_1 }} <= 0 and {{ measure_2 }} <= 0
THEN concat('<div class = "card_dc" style = "border-top: 4px solid #F1C232 !important">', {{ measure_print }}, '</div>')
WHEN {{ measure_1 }} <= 0 and {{ measure_2 }} > 0
THEN concat('<div class = "card_dc" style = "border-top: 4px solid #D93025 !important;background:linear-gradient(180deg, rgba(217,48,37,0.10) 0%, rgba(217,48,37,0) 45%), #ffffff;">',{{ measure_print }}, '</div>')
ELSE {{ measure_print }}
ENDТеперь проблемные места лучше считываются при просмотре дашборда.

Заказчик получил нужный визуал, а я — код, который легко поддерживать и в который просто вносить изменения.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.