Tworzenie 3-stopniowego wykresu przepływu Sankey w Power BI za pomocą Deneb i Vega

Masz dość standardowych wykresów słupkowych w Power BI, które nie potrafią pokazać, jak w rzeczywistości przepływają Twoje dane? 😴 Niezależnie od tego, czy analizujesz alokację wydatków, rentowność produktów czy ścieżki klientów, wielopoziomowy wykres Sankeya to absolutna tajna broń w raportowaniu dla kadry zarządzającej.

W tym krótkim poradniku dowiesz się, jak stworzyć w pełni elastyczny, gotowy do publikacji wykres przepływu Produkt –> Kategoria –> Wydatki ogółem w Power BI za pomocą darmowej wizualizacji niestandardowej Deneb.

Najlepsza część? Wykres automatycznie tworzy ranking najważniejszych pozycji, resztę estetycznie grupuje w kategorii „Other” i idealnie wpasowuje się w każdy nowoczesny dashboard firmowy! 💎

Sankey

🛠️ Czego potrzebujesz do przygotowania wykresu Sankeya

Aby przejść przez ten poradnik, potrzebujesz jedynie:

  • Power BI Desktop
  • Wizualizacji Deneb (darmowa, dostępna w Microsoft AppSource)
  • 3 pól danych: Product, Category, oraz miary Value (np. Sprzedaż, Wydatki ogółem lub Koszt własny sprzedaży – COGS)

🚀 Instrukcja wdrożenia krok po kroku (zajmie Ci to < 2 minuty!)

Krok 1: Zaimportuj wizualizację Deneb 🛍️

  1. Otwórz swój raport w Power BI.
  2. W okienku Wizualizacje (Visualizations) kliknij ikonę wielokropka (…) i wybierz Pobierz więcej wizualizacji (Get more visuals).
  3. Wyszukaj Deneb (deklaratywna specyfikacja wizualizacji oparta na językach Vega/Vega-Lite) i kliknij Dodaj (Add).

Krok 2: Dodaj swoje pola danych 📥

Przeciągnij wizualizację Deneb na kanwę raportu i upuść trzy pola w sekcji Wartości (Values):

  • Pole produktu (np. Nazwa produktu, Product lub Produkt)
  • Pole kategorii (np. Kategoria, Category lub Grupa)
  • Miarę wartości / miarę liczbową (np. Koszt własny sprzedaży – COGS, Wartość, Sprzedaż lub Kwota)

💡 Sprytna funkcja: Kod ma wbudowane automatyczne wykrywanie popularnych angielskich i polskich nazw kolumn, więc nie musisz nawet zmieniać nazw pól w swoim modelu!

Krok 3: Wklej kod Vega 🪄

  1. Najedź kursorem na wizualizację i kliknij ikonę menu w prawym górnym rogu $\rightarrow$Edytuj (Edit).
  2. Po wyświetleniu monitu o wybór frameworka specyfikacji:
    • Wybierz Vega (⚠️ Ważne: Wybierz Vega, nie Vega-Lite!).
    • Wybierz [empty template](pusty szablon).
  3. Skopiuj poniższy, kompletny kod JSON i wklej go do edytora kodu po lewej stronie.
  4. Kliknij Zastosuj zmiany (Apply Changes lub naciśnij Ctrl + Enter).
{
  "$schema": "https://vega.github.io/schema/vega/v5.json",
  "width": 850,
  "height": 520,
  "padding": {"top": 20, "left": 15, "right": 20, "bottom": 20},

  "signals": [
    {"name": "topN", "value": 5},
    {"name": "leftX", "value": 240},
    {"name": "midX", "value": 480},
    {"name": "rightX", "value": 660},
    {"name": "topMargin", "value": 45},
    {"name": "pGap", "value": 8},
    {"name": "cGap", "value": 30},
    {"name": "targetY", "value": 175}
  ],

  "data": [
    {
      "name": "dataset"
    },
    {
      "name": "raw_data",
      "source": "dataset",
      "transform": [
        {
          "type": "formula",
          "expr": "datum.Product != null ? datum.Product : datum['Product Name'] != null ? datum['Product Name'] : datum.Produkt != null ? datum.Produkt : 'Product'",
          "as": "p_raw"
        },
        {
          "type": "formula",
          "expr": "datum.Category != null ? datum.Category : datum.Kategoria != null ? datum.Kategoria : 'Category'",
          "as": "c_raw"
        },
        {
          "type": "formula",
          "expr": "toNumber(datum.Value != null ? datum.Value : datum.Wartosc != null ? datum.Wartosc : datum['Total COGS'] != null ? datum['Total COGS'] : 0)",
          "as": "val"
        }
      ]
    },
    {
      "name": "prod_totals",
      "source": "raw_data",
      "transform": [
        {
          "type": "aggregate",
          "groupby": ["p_raw"],
          "ops": ["sum"],
          "fields": ["val"],
          "as": ["p_tot"]
        },
        {
          "type": "window",
          "sort": {"field": "p_tot", "order": "descending"},
          "ops": ["row_number"],
          "as": ["p_rank"]
        }
      ]
    },
    {
      "name": "ranked_data",
      "source": "raw_data",
      "transform": [
        {
          "type": "lookup",
          "from": "prod_totals",
          "key": "p_raw",
          "fields": ["p_raw"],
          "values": ["p_rank"]
        },
        {
          "type": "formula",
          "expr": "datum.p_rank <= topN ? datum.p_rank : (topN + 1)",
          "as": "p_sort"
        },
        {
          "type": "formula",
          "expr": "datum.p_rank <= topN ? datum.p_rank + '. ' + datum.p_raw : (topN + 1) + '. Other'",
          "as": "p_name"
        }
      ]
    },
    {
      "name": "grand_total",
      "source": "ranked_data",
      "transform": [
        {
          "type": "aggregate",
          "ops": ["sum"],
          "fields": ["val"],
          "as": ["tot_val"]
        }
      ]
    },
    {
      "name": "c_nodes",
      "source": "ranked_data",
      "transform": [
        {
          "type": "aggregate",
          "groupby": ["c_raw"],
          "ops": ["sum"],
          "fields": ["val"],
          "as": ["c_val"]
        },
        {
          "type": "window",
          "sort": {"field": "c_val", "order": "descending"},
          "ops": ["row_number"],
          "as": ["c_rank"]
        },
        {
          "type": "stack",
          "groupby": [],
          "field": "c_val",
          "sort": {"field": "c_val", "order": "descending"},
          "as": ["cy0", "cy1"]
        },
        {
          "type": "formula",
          "expr": "topMargin + scale('yScale', datum.cy0) + (datum.c_rank - 1) * cGap",
          "as": "c_top"
        },
        {
          "type": "formula",
          "expr": "topMargin + scale('yScale', datum.cy1) + (datum.c_rank - 1) * cGap",
          "as": "c_bottom"
        },
        {
          "type": "formula",
          "expr": "(datum.c_top + datum.c_bottom) / 2",
          "as": "c_mid"
        },
        {
          "type": "formula",
          "expr": "max(6, datum.c_bottom - datum.c_top)",
          "as": "c_height"
        }
      ]
    },
    {
      "name": "p_nodes",
      "source": "ranked_data",
      "transform": [
        {
          "type": "aggregate",
          "groupby": ["p_sort", "p_name"],
          "ops": ["sum"],
          "fields": ["val"],
          "as": ["p_val"]
        },
        {
          "type": "window",
          "sort": {"field": "p_sort", "order": "ascending"},
          "ops": ["row_number"],
          "as": ["p_seq"]
        },
        {
          "type": "stack",
          "groupby": [],
          "field": "p_val",
          "sort": {"field": "p_sort", "order": "ascending"},
          "as": ["py0", "py1"]
        },
        {
          "type": "formula",
          "expr": "topMargin + scale('yScale', datum.py0) + (datum.p_seq - 1) * pGap",
          "as": "p_top"
        },
        {
          "type": "formula",
          "expr": "topMargin + scale('yScale', datum.py1) + (datum.p_seq - 1) * pGap",
          "as": "p_bottom"
        },
        {
          "type": "formula",
          "expr": "(datum.p_top + datum.p_bottom) / 2",
          "as": "p_mid"
        },
        {
          "type": "formula",
          "expr": "max(4, datum.p_bottom - datum.p_top)",
          "as": "p_height"
        }
      ]
    },
    {
      "name": "p_c_links",
      "source": "ranked_data",
      "transform": [
        {
          "type": "aggregate",
          "groupby": ["p_sort", "p_name", "c_raw"],
          "ops": ["sum"],
          "fields": ["val"],
          "as": ["link_val"]
        },
        {
          "type": "lookup",
          "from": "c_nodes",
          "key": "c_raw",
          "fields": ["c_raw"],
          "values": ["c_rank", "cy0"]
        },
        {
          "type": "lookup",
          "from": "p_nodes",
          "key": "p_name",
          "fields": ["p_name"],
          "values": ["p_seq", "py0"]
        },
        {
          "type": "stack",
          "groupby": ["p_name"],
          "sort": {"field": "c_rank", "order": "ascending"},
          "field": "link_val",
          "as": ["p_sy0", "p_sy1"]
        },
        {
          "type": "stack",
          "groupby": ["c_raw"],
          "sort": {"field": "p_sort", "order": "ascending"},
          "field": "link_val",
          "as": ["c_sy0", "c_sy1"]
        },
        {
          "type": "formula",
          "expr": "topMargin + scale('yScale', datum.py0 + (datum.p_sy0 + datum.p_sy1)/2) + (datum.p_seq - 1) * pGap",
          "as": "link_p_y"
        },
        {
          "type": "formula",
          "expr": "topMargin + scale('yScale', datum.cy0 + (datum.c_sy0 + datum.c_sy1)/2) + (datum.c_rank - 1) * cGap",
          "as": "link_c_y"
        },
        {
          "type": "formula",
          "expr": "max(1.5, scale('yScale', datum.link_val))",
          "as": "link_width"
        }
      ]
    }
  ],

  "scales": [
    {
      "name": "yScale",
      "type": "linear",
      "domain": {"data": "grand_total", "field": "tot_val"},
      "range": [0, 260],
      "zero": true
    },
    {
      "name": "catColor",
      "type": "ordinal",
      "domain": {"data": "c_nodes", "field": "c_raw"},
      "range": [
        "#0284C7", "#00C896", "#F43F5E", "#0F172A", "#64748B", 
        "#3B82F6", "#10B981", "#E11D48", "#8B5CF6", "#475569"
      ]
    }
  ],

  "marks": [
    {
      "type": "text",
      "encode": {
        "enter": {
          "x": {"signal": "leftX - 10"},
          "y": {"value": 20},
          "align": {"value": "right"},
          "fontSize": {"value": 11},
          "fontWeight": {"value": "bold"},
          "fill": {"value": "#475569"}
        },
        "update": {
          "text": {"signal": "'PRODUCTS (TOP ' + topN + ' + OTHER)'"}
        }
      }
    },
    {
      "type": "text",
      "encode": {
        "enter": {
          "x": {"signal": "midX"},
          "y": {"value": 20},
          "align": {"value": "center"},
          "fontSize": {"value": 11},
          "fontWeight": {"value": "bold"},
          "fill": {"value": "#475569"},
          "text": {"value": "CATEGORIES"}
        }
      }
    },
    {
      "type": "text",
      "encode": {
        "enter": {
          "x": {"signal": "rightX + 22"},
          "y": {"value": 20},
          "align": {"value": "left"},
          "fontSize": {"value": 11},
          "fontWeight": {"value": "bold"},
          "fill": {"value": "#475569"},
          "text": {"value": "TOTAL SPEND"}
        }
      }
    },

    {
      "type": "path",
      "from": {"data": "p_c_links"},
      "encode": {
        "update": {
          "path": {
            "signal": "'M ' + (leftX + 6) + ' ' + datum.link_p_y + ' C ' + (leftX + 100) + ' ' + datum.link_p_y + ', ' + (midX - 100) + ' ' + datum.link_c_y + ', ' + (midX - 8) + ' ' + datum.link_c_y"
          },
          "stroke": {"scale": "catColor", "field": "c_raw"},
          "strokeWidth": {"field": "link_width"},
          "strokeOpacity": {"value": 0.40},
          "strokeCap": {"value": "round"}
        }
      }
    },

    {
      "type": "path",
      "from": {"data": "c_nodes"},
      "encode": {
        "update": {
          "path": {
            "signal": "'M ' + (midX + 8) + ' ' + datum.c_mid + ' C ' + (midX + 80) + ' ' + datum.c_mid + ', ' + (rightX - 50) + ' ' + targetY + ', ' + rightX + ' ' + targetY"
          },
          "stroke": {"scale": "catColor", "field": "c_raw"},
          "strokeWidth": {"field": "c_height"},
          "strokeOpacity": {"value": 0.35},
          "strokeCap": {"value": "round"}
        }
      }
    },

    {
      "type": "rect",
      "from": {"data": "p_nodes"},
      "encode": {
        "update": {
          "x": {"signal": "leftX - 6"},
          "y": {"field": "p_top"},
          "width": {"value": 12},
          "height": {"field": "p_height"},
          "fill": {"signal": "datum.p_sort > topN ? '#94A3B8' : '#0F172A'"},
          "cornerRadius": {"value": 3}
        }
      }
    },

    {
      "type": "text",
      "from": {"data": "p_nodes"},
      "encode": {
        "enter": {
          "align": {"value": "right"},
          "baseline": {"value": "middle"},
          "fontSize": {"value": 10},
          "fontWeight": {"value": "bold"},
          "fill": {"value": "#1E293B"}
        },
        "update": {
          "x": {"signal": "leftX - 14"},
          "y": {"field": "p_mid"},
          "text": {"signal": "datum.p_name + ' (' + format(datum.p_val, ',.0f') + ')'"}
        }
      }
    },

    {
      "type": "rect",
      "from": {"data": "c_nodes"},
      "encode": {
        "update": {
          "x": {"signal": "midX - 8"},
          "y": {"field": "c_top"},
          "width": {"value": 16},
          "height": {"field": "c_height"},
          "fill": {"scale": "catColor", "field": "c_raw"},
          "cornerRadius": {"value": 3}
        }
      }
    },

    {
      "type": "text",
      "from": {"data": "c_nodes"},
      "encode": {
        "enter": {
          "align": {"value": "center"},
          "baseline": {"value": "bottom"},
          "fontSize": {"value": 10},
          "fontWeight": {"value": "bold"},
          "fill": {"value": "#1E293B"}
        },
        "update": {
          "x": {"signal": "midX"},
          "y": {"signal": "datum.c_top - 4"},
          "text": {"signal": "datum.c_raw + ' (' + format(datum.c_val, ',.0f') + ')'"}
        }
      }
    },

    {
      "type": "rect",
      "from": {"data": "grand_total"},
      "encode": {
        "update": {
          "x": {"signal": "rightX"},
          "y": {"signal": "targetY - 30"},
          "width": {"value": 12},
          "height": {"value": 60},
          "fill": {"value": "#0F172A"},
          "cornerRadius": {"value": 4}
        }
      }
    },

    {
      "type": "text",
      "from": {"data": "grand_total"},
      "encode": {
        "enter": {
          "align": {"value": "left"},
          "baseline": {"value": "bottom"},
          "fontSize": {"value": 13},
          "fontWeight": {"value": "bold"},
          "fill": {"value": "#0F172A"}
        },
        "update": {
          "x": {"signal": "rightX + 22"},
          "y": {"signal": "targetY - 2"},
          "text": {"signal": "format(datum.tot_val, ',.0f')"}
        }
      }
    },
    {
      "type": "text",
      "from": {"data": "grand_total"},
      "encode": {
        "enter": {
          "align": {"value": "left"},
          "baseline": {"value": "top"},
          "fontSize": {"value": 10},
          "fill": {"value": "#64748B"},
          "text": {"value": "Total Budget"}
        },
        "update": {
          "x": {"signal": "rightX + 22"},
          "y": {"signal": "targetY + 2"}
        }
      }
    }
  ]
}

Krok 4: Dostosuj wykres jak zawodowiec ⚙️

Chcesz precyzyjnie dopasować wizualizację do motywu swojego raportu? Możesz z łatwością zmodyfikować sekcję sygnałów (Signals) na samej górze kodu:

  • 🔝 Zmień liczbę elementów TOP N: Znajdź parametr "topN": 5 w bloku sygnałów. Zmień wartość na 3, 8 lub 10 w zależności od tego, ile kluczowych produktów chcesz wyeksponować przed zgrupowaniem pozostałych w kategorii „Pther”.
  • 🎨 Paleta kolorów: Dopasuj barwy idealnie do identyfikacji wizualnej swojej marki, edytując kody kolorów HEX w tablicy skali "catColor"!

🎯 Dlaczego Twoi odbiorcy i interesariusze pokochają ten wykres:

  • ✅ 100% integralności danych: Nigdy nie pomija danych z „długiego ogona” – najważniejsze pozycje są wyeksponowane, podczas gdy mniejsze wartości płynnie łączą się w węzeł „Other”.
  • ✅ Zero nakładania się elementów: Automatycznie kalkulowane przesunięcia pionowe gwarantują, że etykiety kategorii i wstęgi przepływu nigdy się nie zderzają ani nie ucinają.
  • ✅ Wyrównanie co do piksela: Wszystkie strumienie zbiegają się płynnie w estetyczny, zwięzły węzeł końcowy z sumą budżetu (Total Budget).

💬 Teraz Twoja kolej!

Wypróbowałeś to rozwiązanie w swoim raporcie Power BI? Daj znać w komentarzach poniżej, jakie przepływy danych analizujesz – i koniecznie pochwal się zrzutem ekranu swojego nowego dashboardu! 📊✨