Bollywood HungamaSCOOP: Ramayana advance booking may get DELAYED due to revenue-sharing tussle; distributors demand 60% share in Week 1PunchJigawa ADC suspends NWC member Gantsa for 12 monthsCNN TürkAnkara'da uyuşturucu satıcılarına darbeESPNTransfer rumors, news: Premier League clubs eye Alexander-Arnold amid Real Madrid uncertaintyInquirer EntertainmentAtasha Muhlach able to vindicate self in ‘Bongga Ka ‘Day’ musical, says John Lapus한겨레미 국방장관, 네타냐후와 비밀 회동…선거 후 ‘이란 폭격설’ 힘 얻나InquirerPangasinan may have sufficient irrigation until 2027 palay seasonSouth China Morning PostCalls to sue Indonesia over haze; Pinduoduo craze sweeps Malaysia: 7 Asia highlightsSky TG24Florida, alligatore lungo quasi 4 metri uccide un uomo: ore per recuperare il corpoZDF heuteAktuelle Pressemitteilungen des ZDFObservador DesportoAcampada. O relato da retirada das tendas em LisboaSportstarRanji Trophy Live Score Day 3: Mumbai takes 388-run lead over HP; J&K 123/9 vs Vidarbha; Services needs 104 runs to beat Bengal
The Daily Newsstand · Free, Always
Tuesday, October 20, 2026

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

Translate

С версии 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

Теперь проблемные места лучше считываются при просмотре дашборда.

Заказчик получил нужный визуал, а я — код, который легко поддерживать и в который просто вносить изменения.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.