Sankey Chart Adjustments - moving/aligning specific starting flows to the right

Hi everyone, not an expert here. maybe someone can help: How do I make the Deficit flow start below/aligned with Budgetary revenues. All my flows are starting at the beginning. :frowning: I tried custom_positions <- tibble::tribble( ~name, ~x, ~y, "Deficit", 0.6, 0.5), but it's not working. Here is my code.

Packages

pkgs <- c("dplyr", "tidyr", "tibble", "networkD3", "htmlwidgets")
to_install <- setdiff(pkgs, rownames(installed.packages()))
if (length(to_install)) install.packages(to_install)
lapply(pkgs, library, character.only = TRUE)

Data

flows <- tibble::tribble(
~source, ~value, ~target, ~hex,
"Personal income tax", 214, "Income taxes", "#02205F",
"Corporate income tax", 86, "Income taxes", "#02205F",
"Non-resident income tax", 13, "Income taxes", "#02205F",
"Goods and Services Tax", 51, "Excise taxes/duties", "#67799F",
"Custom imports duties", 7, "Excise taxes/duties", "#67799F",
"Other taxes/duties", 12, "Excise taxes/duties", "#67799F",
"Income taxes", 313, "Budgetary revenues", "#76BDD9",
"Excise taxes/duties", 70, "Budgetary revenues", "#98CEE2",
"EI premium revenues", 28, "Budgetary revenues", "#BADEEC",
"Pollution pricing", 10, "Budgetary revenues", "#BADEEC",
"Enterprise Crown corporation", 6, "Budgetary revenues", "#BADEEC",
"Other programs", 27, "Budgetary revenues", "#BADEEC",
"Net foreign exchange", 3, "Budgetary revenues", "#BADEEC",
"Budgetary revenues", 457, "Budgetary expenses", "#7E7E7F",
"Deficit", 40, "Budgetary expenses", "#7E7E7F",
"Budgetary expenses", 6, "Actuarial loss", "#FFC000",
"Budgetary expenses", 44, "Public Debt charges", "#FFC000",
"Budgetary expenses", 100, "Transfers to other government", "#FFC000",
"Budgetary expenses", 126, "Transfers to persons", "#FFC000",
"Budgetary expenses", 221, "Direct program expenses", "#FFC000",
"Transfers to other government", 53, "Health Transfers", "#FFE699",
"Transfers to other government", 16, "Social Transfers", "#FFE699",
"Transfers to other government", 24, "Equalization", "#FFE699",
"Transfers to other government", 5, "Territorial Formula Financing", "#FFE699",
"Transfers to other government", 6, "Child care", "#FFE699",
"Transfers to other government", 2, "Community-Building", "#FFE699",
"Transfers to persons", 24, "EI benefits", "#FFE699",
"Transfers to persons", 76, "Elderly benefits", "#FFE699",
"Transfers to persons", 26, "Canada Child Benefit", "#FFE699",
"Direct program expenses", 124, "Operating expense", "#FFE699",
"Direct program expenses", 86, "Other transfer payments", "#FFE699",
"Direct program expenses", 11, "Fuel charge proceeds return", "#FFE699"
)

Nodes

nodes <- tibble(name = unique(c(flows$source, flows$target)))
id_map <- setNames(seq_len(nrow(nodes)) - 1L, nodes$name)

links <- flows |>
mutate(
source = id_map[source],
target = id_map[target],
group = hex
)

Colors

node_colors <- flows |>
distinct(source, hex) |>
rename(name = source, group = hex) |>
right_join(nodes, by = "name") |>
mutate(group = ifelse(is.na(group), "#999999", group))

Scale

all_hex <- sort(unique(c(links$group, node_colors$group)))
colourScale <- paste0(
"d3.scaleOrdinal()",
".domain([", paste(sprintf("'%s'", all_hex), collapse = ","), "])",
".range([", paste(sprintf("'%s'", all_hex), collapse = ","), "])"
)

Sankey

p <- networkD3::sankeyNetwork(
Links = links,
Nodes = node_colors,
Source = "source",
Target = "target",
Value = "value",
NodeID = "name",
NodeGroup = "group",
LinkGroup = "group",
sinksRight = TRUE,
fontSize = 13,
nodeWidth = 24,
nodePadding = 18,
colourScale = colourScale
)

Hover

p <- htmlwidgets::onRender(
p,
"
function(el,x){
d3.select(el).selectAll('.link')
.style('stroke-opacity',0.35)
.on('mouseover', function(){ d3.select(this).style('stroke-opacity',0.6); })
.on('mouseout', function(){ d3.select(this).style('stroke-opacity',0.35); });
d3.select(el).selectAll('.node text').style('font-weight',600);
}"
)

#Display
p
htmlwidgets::saveWidget(p, 'budget_sankey1.html', selfcontained = TRUE)

If I understand your question correctly...

This is not a super satisfying, nor a sensibly generalized solution, but it should work for this specific case and could be adapted to other cases...

# Packages
library(dplyr)
library(tidyr)
library(tibble)
library(networkD3)
library(htmlwidgets)

# Data
flows <- tibble::tribble(
  ~source, ~value, ~target, ~hex,
  "Personal income tax", 214, "Income taxes", "#02205F",
  "Corporate income tax", 86, "Income taxes", "#02205F",
  "Non-resident income tax", 13, "Income taxes", "#02205F",
  "Goods and Services Tax", 51, "Excise taxes/duties", "#67799F",
  "Custom imports duties", 7, "Excise taxes/duties", "#67799F",
  "Other taxes/duties", 12, "Excise taxes/duties", "#67799F",
  "Income taxes", 313, "Budgetary revenues", "#76BDD9",
  "Excise taxes/duties", 70, "Budgetary revenues", "#98CEE2",
  "EI premium revenues", 28, "Budgetary revenues", "#BADEEC",
  "Pollution pricing", 10, "Budgetary revenues", "#BADEEC",
  "Enterprise Crown corporation", 6, "Budgetary revenues", "#BADEEC",
  "Other programs", 27, "Budgetary revenues", "#BADEEC",
  "Net foreign exchange", 3, "Budgetary revenues", "#BADEEC",
  "Budgetary revenues", 457, "Budgetary expenses", "#7E7E7F",
  "Deficit", 40, "Budgetary expenses", "#7E7E7F",
  "Budgetary expenses", 6, "Actuarial loss", "#FFC000",
  "Budgetary expenses", 44, "Public Debt charges", "#FFC000",
  "Budgetary expenses", 100, "Transfers to other government", "#FFC000",
  "Budgetary expenses", 126, "Transfers to persons", "#FFC000",
  "Budgetary expenses", 221, "Direct program expenses", "#FFC000",
  "Transfers to other government", 53, "Health Transfers", "#FFE699",
  "Transfers to other government", 16, "Social Transfers", "#FFE699",
  "Transfers to other government", 24, "Equalization", "#FFE699",
  "Transfers to other government", 5, "Territorial Formula Financing", "#FFE699",
  "Transfers to other government", 6, "Child care", "#FFE699",
  "Transfers to other government", 2, "Community-Building", "#FFE699",
  "Transfers to persons", 24, "EI benefits", "#FFE699",
  "Transfers to persons", 76, "Elderly benefits", "#FFE699",
  "Transfers to persons", 26, "Canada Child Benefit", "#FFE699",
  "Direct program expenses", 124, "Operating expense", "#FFE699",
  "Direct program expenses", 86, "Other transfer payments", "#FFE699",
  "Direct program expenses", 11, "Fuel charge proceeds return", "#FFE699"
)

# Nodes
nodes <- tibble(name = unique(c(flows$source, flows$target)))
id_map <- setNames(seq_len(nrow(nodes)) - 1L, nodes$name)

links <- flows |>
  mutate(
    source = id_map[source],
    target = id_map[target],
    group = hex
  )

# Colors
node_colors <- flows |>
  distinct(source, hex) |>
  rename(name = source, group = hex) |>
  right_join(nodes, by = "name") |>
  mutate(group = ifelse(is.na(group), "#999999", group))

# Scale
all_hex <- sort(unique(c(links$group, node_colors$group)))
colourScale <- paste0(
  "d3.scaleOrdinal()",
  ".domain([", paste(sprintf("'%s'", all_hex), collapse = ","), "])",
  ".range([", paste(sprintf("'%s'", all_hex), collapse = ","), "])"
)

# Sankey
p <- networkD3::sankeyNetwork(
  Links = links,
  Nodes = node_colors,
  Source = "source",
  Target = "target",
  Value = "value",
  NodeID = "name",
  NodeGroup = "group",
  LinkGroup = "group",
  sinksRight = TRUE,
  fontSize = 13,
  nodeWidth = 24,
  nodePadding = 18,
  colourScale = colourScale
)

# modify sankey with JavaScript
htmlwidgets::onRender(
  x = p,
  jsCode = "
    function(el,x){
      sankey = this.sankey;
      nodes = d3.select(el).select('svg').selectAll('.node');
      links = d3.select(el).select('svg').selectAll('.link');
      path = this.sankey.link();

      // match 'Deficit' node's x to 'Budgetary revenues' node
      sankey.nodes().filter(d => d.name == 'Deficit')[0].x = sankey.nodes().filter(d => d.name == 'Budgetary revenues')[0].x;

      // update positions
      nodes.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')');
      links.attr('d', this.sankey.link());
    }
  "
)

Created on 2025-10-29 with reprex v2.1.1

Many thanks. Cereative. :slight_smile: The issue is that i have to realign all the blue with the blue and all the grey with grey. I have the perfect sankey with sankeymatic but I wanted a shinyapp for it .

Yeah, many more recent sankey tools have a sinksLeft or similar feature, but networkD3 will never get it since it's unmaintained and I don't have admin access to it, so there's only work arounds using htmlwidgets. I have (sometimes successfully) experimented with overriding internal JS functions and re-rendering through htmlwidgets, so it is hypothetically possible to rewrite the layout code to "sink left", but I don't have the motivation for it.

Years ago I started making a sequel to networkD3 leveraging RStudio/Posit's r2d3, which does have this feature plus a ton of other improvements and options, but now r2d3 is effectively abandoned (no response from maintainer for more than a year), so I've mostly lost motivation for that too.

But you can install it from GitHub and/or R-universe and use it (documentation and manual color assignment sucks, because again -> no motivation), e.g.

# Packages
library(dplyr)
library(tidyr)
library(tibble)
library(network.r2d3)

# Data
flows <- tibble::tribble(
  ~source, ~value, ~target, ~hex,
  "Personal income tax", 214, "Income taxes", "#02205F",
  "Corporate income tax", 86, "Income taxes", "#02205F",
  "Non-resident income tax", 13, "Income taxes", "#02205F",
  "Goods and Services Tax", 51, "Excise taxes/duties", "#67799F",
  "Custom imports duties", 7, "Excise taxes/duties", "#67799F",
  "Other taxes/duties", 12, "Excise taxes/duties", "#67799F",
  "Income taxes", 313, "Budgetary revenues", "#76BDD9",
  "Excise taxes/duties", 70, "Budgetary revenues", "#98CEE2",
  "EI premium revenues", 28, "Budgetary revenues", "#BADEEC",
  "Pollution pricing", 10, "Budgetary revenues", "#BADEEC",
  "Enterprise Crown corporation", 6, "Budgetary revenues", "#BADEEC",
  "Other programs", 27, "Budgetary revenues", "#BADEEC",
  "Net foreign exchange", 3, "Budgetary revenues", "#BADEEC",
  "Budgetary revenues", 457, "Budgetary expenses", "#7E7E7F",
  "Deficit", 40, "Budgetary expenses", "#7E7E7F",
  "Budgetary expenses", 6, "Actuarial loss", "#FFC000",
  "Budgetary expenses", 44, "Public Debt charges", "#FFC000",
  "Budgetary expenses", 100, "Transfers to other government", "#FFC000",
  "Budgetary expenses", 126, "Transfers to persons", "#FFC000",
  "Budgetary expenses", 221, "Direct program expenses", "#FFC000",
  "Transfers to other government", 53, "Health Transfers", "#FFE699",
  "Transfers to other government", 16, "Social Transfers", "#FFE699",
  "Transfers to other government", 24, "Equalization", "#FFE699",
  "Transfers to other government", 5, "Territorial Formula Financing", "#FFE699",
  "Transfers to other government", 6, "Child care", "#FFE699",
  "Transfers to other government", 2, "Community-Building", "#FFE699",
  "Transfers to persons", 24, "EI benefits", "#FFE699",
  "Transfers to persons", 76, "Elderly benefits", "#FFE699",
  "Transfers to persons", 26, "Canada Child Benefit", "#FFE699",
  "Direct program expenses", 124, "Operating expense", "#FFE699",
  "Direct program expenses", 86, "Other transfer payments", "#FFE699",
  "Direct program expenses", 11, "Fuel charge proceeds return", "#FFE699"
)

# Nodes
nodes <- tibble(name = unique(c(flows$source, flows$target)))
id_map <- setNames(seq_len(nrow(nodes)) - 1L, nodes$name)

links <- flows |>
  mutate(
    source = id_map[source],
    target = id_map[target],
    group = hex
  )

# Colors
node_colors <- flows |>
  distinct(source, hex) |>
  rename(name = source, group = hex) |>
  right_join(nodes, by = "name") |>
  mutate(group = ifelse(is.na(group), "#999999", group))

# Scale
all_hex <- sort(unique(c(links$group, node_colors$group)))
colourScale <- paste0(
  "d3.scaleOrdinal()",
  ".domain([", paste(sprintf("'%s'", all_hex), collapse = ","), "])",
  ".range([", paste(sprintf("'%s'", all_hex), collapse = ","), "])"
)

# Sankey

sankey_data <- list(links = flows, nodes = node_colors)

network.r2d3::sankey_network(
  data = sankey_data,
  nodeAlign = "sankeyRight"
)

# explore options with:
# network.r2d3::sankey_explorer(sankey_data)

Created on 2025-10-30 with reprex v2.1.1

thank you so much for all your input and this info. i went with this solution but i am having difficulties nudging Custom import duties and other taxes/duties upward. it's cause of the snap arrangement i think. but when i set fixed, the whole plot kaboum! import plotly.graph_objects as go

----------------------------------------------------

1. Define nodes

----------------------------------------------------

nodes = [
# L1 - Revenues
"Personal income tax", "Corporate income tax", "Non-resident income tax",
"Goods and Services Tax", "Other taxes/duties", "Customs import duties",

# L2 - Direct revenues and intermediates
"Income taxes", "Excise taxes/duties",
"Other programs", "EI premium revenues", "Pollution pricing",
"Enterprise Crown corporations", "Net foreign exchange",

# L3 - Central
"Budgetary revenues", "Deficit", "Budgetary expenses",

# L4 - Spending categories
"Transfers to other governments", "Public debt charges",
"Net actuarial losses", "Transfers to persons", "Direct program expenses",

# L5 - Detailed breakdowns
"Health transfers", "Equalization", "Social transfers", "Child care",
"Territorial Formula Financing", "Community building",
"Elderly benefits", "Canada Child Benefit", "EI benefits",
"Operating expenses", "Other transfer payments",
"Pollution pricing proceeds returned to Canadians"

]

----------------------------------------------------

2. Define connections and approximate values

----------------------------------------------------

sources = [
# L1 → L2
0, 1, 2, # Income tax sources
3, 4, 5, # Excise tax sources
# L2 → L3
6, 7, 8, 9, 10, 11, 12,
# L3 → L3
13, 14,
# L3 → L4
15, 15, 15, 15, 15,
# L4 → L5
16, 16, 16, 16, 16, 16,
19, 19, 19,
20, 20, 20
]

targets = [
# L1 → L2
6, 6, 6, # → Income taxes
7, 7, 7, # → Excise taxes/duties
# L2 → L3
13, 13, 13, 13, 13, 13, 13, # All revenue nodes → Budgetary revenues
# L3 → L3
15, 15, # Budgetary revenues & deficit → Budgetary expenses
# L3 → L4
16, 17, 18, 19, 20,
# L4 → L5
21, 22, 23, 24, 25, 26,
27, 28, 29,
30, 31, 32
]

values = [
# L1 → L2
233, 91, 13,
54, 16, 6,
# L2 → L3
337, 76, 31, 30, 13, 9, 4,
# L3 → L3
498, 40,
# L3 → L4
112, 54, 3, 136, 240,
# L4 → L5
56, 25, 17, 7, 5, 2,
81, 28, 27,
123, 102, 15
]

----------------------------------------------------

3. Colors by type

----------------------------------------------------

link_colors = (
["#1F4E79"]*3 + # Income tax
["#597FA9"]*3 + # Excise
["#A9CCE3"]*7 + # Direct revenues
["#B0B0B0"]*2 + # Core connections
["#F5D46C"]*5 + # Major spending
["#F7E08B"]*6 + # Transfers to govs
["#F2C94C"]*3 + # Transfers to persons
["#E3B03E"]*3 # Direct program expenses
)

----------------------------------------------------

4. Node positioning (L2 alignment for all revenue categories)

----------------------------------------------------

x_positions = [
# L1
*[0.0]*6,
# L2 (Income, Excise, and Other direct revenues)
*[0.20]*7,
# L3
0.40, 0.40, 0.50,
# L4
*[0.70]*5,
# L5
*[0.90]*12
]

y_positions = [
# L1
0.00, 0.08, 0.16, 0.24, 0.32, 0.40,
# L2
0.10, 0.25, 0.40, 0.50, 0.58, 0.65, 0.72,
# L3
0.40, 0.48, 0.50,
# L4
0.20, 0.35, 0.45, 0.60, 0.75,
# L5
0.15, 0.22, 0.29, 0.36, 0.43, 0.50,
0.60, 0.66, 0.72,
0.80, 0.86, 0.92
]

----------------------------------------------------

5. Build Sankey diagram

----------------------------------------------------

fig = go.Figure(data=[go.Sankey(
arrangement="snap",
node=dict(
pad=20,
thickness=20,
line=dict(color="black", width=0.3),
label=nodes,
color=[
*["#1F4E79"]*6, # L1
*["#76BDD9"]*7, # L2
*["#B0B0B0"]*3, # L3
*["#F5D46C"]*5, # L4
*["#F7E08B"]*6, # L5 gov
*["#F2C94C"]*3, # L5 persons
*["#E3B03E"]*3 # L5 direct
],
x=x_positions,
y=y_positions
),
link=dict(
source=sources,
target=targets,
value=values,
color=link_colors
)
)])

----------------------------------------------------

6. Layout

----------------------------------------------------

fig.update_layout(
title_text="Canada Federal Budget Flow (All revenue categories aligned at L2)",
font=dict(size=11, color="black"),
height=750,
width=1600, # Increased width
paper_bgcolor="white",
plot_bgcolor="white",
margin=dict(l=40, r=40, t=60, b=40)
)

fig.show()

i am an idiot. fixed it # L1
0.00, 0.08, 0.10, 0.16, 0.30, 0.32,

This topic was automatically closed 7 days after the last reply. New replies are no longer allowed.

If you have a query related to it or one of the replies, start a new topic and refer back with a link.