Draws a sankey chart: the flows between the nodes of a graph
A category of a sankey is a path of two nodes or more, such as
["Coal", "Electricity"] or "Gas/Electricity/Homes". Its value goes
along each link of the path. links/4 adds the values of the same link.
Thus a CSV file can hold one row for each flow, or one row for each record
of a path with the levels option.
The layout has these rules:
- A node with no flow into it is in the first column. Each other node is
one column after its last source. Thus a link can go across a column.
The
columnof a node entity sets the column of its node. - The height of a node is the larger of the flow into it and the flow out of it. One scale sets the height of each node, and the fullest column fills the height of the chart. The nodes of each column are in the vertical middle of the chart.
- The nodes of a column keep the order of the data, or the
orderof their node entity. At each node, the links have the order of their other ends, from the top, so few links cross. - A link is a ribbon between two cubic Bézier curves. The control points are at the middle between the two columns, so a link leaves and enters each node horizontally. The ribbon is a filled shape, and not a thick stroke, so its height is its value at each point.
An alluvial chart has one node for each name in each column. The column of
a node is its place in the path, so the same name in two columns becomes
two nodes. Its node is the tuple {column, name}, as links/4 returns it. The
nodes of one name have one color and one place in each column.
A graph with a loop has no first column. back_links/1 finds the links
that close a loop. The layout finds the columns from the other links, and
a back link goes in a lane under the nodes. The frames of a chart share the
columns, the order and the scale of the nodes, as layout_frames/3
returns them. Each frame is a layer, and the opacity of the layers changes
from one frame to the next.
render/1 draws each series of the chart as a group. The group holds the
links of the series and the nodes that show first with it. The node labels
of each series are in a group after the links, with the same steps.
table/1 writes a row for each link for a screen reader.
Summary
Types
A node: its name, or its column from 0 and its name in an alluvial chart
A link: two nodes and a value
Functions
Return the links that close a loop, as pairs of a source and a target
Return the text of a node in the table
Make sure that the categories and the values make a graph
Return the column of each node, from 0
Return the attributes that place the label of a node: at the right of the node, and in the middle of its height
Return the text of the label of a node: its label, and its value when the chart shows the values
Mark each node that shows its label with the key labeled
Place the nodes and the links of a sankey in the box of the SVG
Place the nodes and the links of each frame of a sankey
Return the links of the paths, with the sum of the values of each link
Return the name of a node
Return the width of a node in the units of the SVG
Return the nodes of a category of a sankey, as a list of names
Return the columns that the node entities give, from 0
Draw the links, the nodes and the labels of a sankey in its SVG
Return the path of a ribbon from (x0, y0) to (x1, y1), with a height
Return the headers and the rows of the table of a sankey: one row for each link, with its source, its target and its value in each frame
Draw the title of each column of an alluvial chart or a parallel sets chart, in the row above the nodes
Tell whether a chart shows a title over each column: an alluvial chart or
a parallel sets chart with the levels option
Types
Functions
Return the links that close a loop, as pairs of a source and a target
A depth-first search from each node, in the order of the data, follows
the links in their order. A link to a node on the path of the search
closes a loop. The graph without these links has no loop, so columns/2
uses it to find the columns.
iex> links = [%{from: "A", to: "B"}, %{from: "B", to: "C"}, %{from: "C", to: "A"}]
iex> Expresso.Element.Chart.Sankey.back_links(links)
MapSet.new([{"C", "A"}])
Return the text of a node in the table
A node of an alluvial chart names its column, because one name can be in each column.
@spec check([String.t() | [String.t()]], [number()], String.t(), boolean()) :: :ok | {:error, String.t()}
Make sure that the categories and the values make a graph
Each category names two nodes or more, each node has a name, and no link
goes from a node to the same node. Each value is zero or more, and their
sum is more than zero. A loop of two nodes or more is correct:
back_links/1 finds the links that close it. With alluvial true, each
column has its own nodes, so a name can follow the same name.
iex> Expresso.Element.Chart.Sankey.check([["A", "B"], "B/C"], [1, 2], "/")
:ok
iex> Expresso.Element.Chart.Sankey.check([["A", "B"], ["B", "A"]], [1, 2], "/")
:ok
iex> Expresso.Element.Chart.Sankey.check([["A", "A"]], [1], "/")
{:error, "a flow of a sankey goes from \"A\" to \"A\". A flow goes to another node."}
iex> Expresso.Element.Chart.Sankey.check([["A", "A"], ["A", "B"]], [1, 2], "/", true)
:ok
@spec columns([%{from: key(), to: key()}], %{ required(String.t()) => non_neg_integer() }) :: %{ required(key()) => non_neg_integer() }
Return the column of each node, from 0
A node with no link into it is in column 0. Each other node is one column
after its last source. A link that closes a loop, as back_links/1 finds
it, does not count. pinned holds the columns of some nodes, from 0, and
the nodes after them move to follow them. The pin of a node that no link
touches does not count. A node of an alluvial chart holds its column.
iex> links = [%{from: "A", to: "B"}, %{from: "B", to: "C"}, %{from: "A", to: "C"}]
iex> Expresso.Element.Chart.Sankey.columns(links)
%{"A" => 0, "B" => 1, "C" => 2}
iex> Expresso.Element.Chart.Sankey.columns(links, %{"C" => 4})
%{"A" => 0, "B" => 1, "C" => 4}
iex> Expresso.Element.Chart.Sankey.columns(links, %{"D" => 4})
%{"A" => 0, "B" => 1, "C" => 2}
iex> Expresso.Element.Chart.Sankey.columns([%{from: "A", to: "B"}, %{from: "B", to: "A"}])
%{"A" => 0, "B" => 1}
iex> Expresso.Element.Chart.Sankey.columns([%{from: {0, "A"}, to: {1, "A"}}])
%{{0, "A"} => 0, {1, "A"} => 1}
Return the attributes that place the label of a node: at the right of the node, and in the middle of its height
iex> Expresso.Element.Chart.Sankey.label_place(%{x: 4, y: 10, height: 20})
[x: "28", y: "27", text_anchor: "start"]
Return the text of the label of a node: its label, and its value when the chart shows the values
iex> Expresso.Element.Chart.Sankey.label_text(%{label: "Gas", value: 95}, true)
"Gas 95"
Mark each node that shows its label with the key labeled
A node as high as a label shows its label. A shorter node shows its label when the middle of the label is one line or more from the middle of each other label in its column, from the top down, so two labels do not cover each other.
iex> nodes = [
...> %{name: "A", column: 0, y: 0, height: 40},
...> %{name: "B", column: 0, y: 54, height: 6},
...> %{name: "C", column: 0, y: 64, height: 4}
...> ]
iex> nodes |> Expresso.Element.Chart.Sankey.labeled() |> Enum.map(& &1.labeled)
[true, true, false]
@spec layout( [%{from: String.t(), to: String.t(), value: number(), series: pos_integer()}], number(), %{ required(String.t()) => Expresso.Element.Node.t() } ) :: %{ nodes: [map()], links: [map()], node_width: pos_integer(), xs: [number()] }
Place the nodes and the links of a sankey in the box of the SVG
links holds each link with its value and the number of its series, from
- The function returns the nodes, with their boxes, colors and the number
of their first series, and the links, with their paths.
topis the top of the box, andsettingsholds theExpresso.Element.Nodeof each node with anodeentity.
@spec layout_frames([[map()]], number(), %{ required(String.t()) => Expresso.Element.Node.t() }) :: [ %{nodes: [map()], links: [map()], node_width: pos_integer(), xs: [number()]} ]
Place the nodes and the links of each frame of a sankey
frames holds the links of each frame, as layout/2 takes them. The
frames share the columns, the order and the colors of the nodes, from the
links of each frame together, and one scale, so a height means the same
value in each frame. A node or a link with no value in a frame does not
show in that frame.
Return the links of the paths, with the sum of the values of each link
The links keep the order of their first path, and each pair of neighbors
in a path is a link. With alluvial true, each node is the tuple of its
place in the path and its name.
iex> Expresso.Element.Chart.Sankey.links([["A", "B", "C"], ["A", "B"]], [2, 3], "/")
[%{from: "A", to: "B", value: 5}, %{from: "B", to: "C", value: 2}]
iex> Expresso.Element.Chart.Sankey.links([["A", "A"]], [2], "/", true)
[%{from: {0, "A"}, to: {1, "A"}, value: 2}]
Return the name of a node
iex> Expresso.Element.Chart.Sankey.name("Gas")
"Gas"
iex> Expresso.Element.Chart.Sankey.name({2, "Gas"})
"Gas"
@spec node_width() :: pos_integer()
Return the width of a node in the units of the SVG
iex> Expresso.Element.Chart.Sankey.node_width()
16
Return the nodes of a category of a sankey, as a list of names
A category is a list of names, or a string with the separator between two names. The spaces next to a name do not count. A path of an alluvial chart after the check holds its nodes, and the function returns them.
iex> Expresso.Element.Chart.Sankey.path("Gas / Electricity", "/")
["Gas", "Electricity"]
iex> Expresso.Element.Chart.Sankey.path(["I/O", "Disks"], "/")
["I/O", "Disks"]
@spec pinned(%{required(String.t()) => Expresso.Element.Node.t()}) :: %{ required(String.t()) => non_neg_integer() }
Return the columns that the node entities give, from 0
iex> Expresso.Element.Chart.Sankey.pinned(%{"A" => %Expresso.Element.Node{name: "A", column: 3}})
%{"A" => 2}
@spec render(map()) :: Phoenix.HTML.safe()
Draw the links, the nodes and the labels of a sankey in its SVG
The assigns are the assigns of Expresso.Element.Chart. Each category is a
link, and the value of a link is in one series. A chart with frames has one
layer for each frame. The layer of the frame that shows has the opacity 1.
The channel --k<n> of Expresso.Element.Chart.Motion sets the opacity,
so the opacity of the layers changes from one frame to the next.
Return the path of a ribbon from (x0, y0) to (x1, y1), with a height
The top edge and the bottom edge are cubic Bézier curves. Their control points are at the middle, so the ribbon leaves and enters horizontally.
iex> Expresso.Element.Chart.Sankey.ribbon(0, 0, 100, 50, 10)
"M0,0 C50,0 50,50 100,50 L100,60 C50,60 50,10 0,10 Z"
Return the headers and the rows of the table of a sankey: one row for each link, with its source, its target and its value in each frame
@spec titles(map()) :: Phoenix.HTML.safe()
Draw the title of each column of an alluvial chart or a parallel sets chart, in the row above the nodes
The first title is at the left edge of its column, and the last title is
at the right edge of its column. Each other title is at the middle of its
column. The key xs holds the x of each column, as the layout returns it.
Tell whether a chart shows a title over each column: an alluvial chart or
a parallel sets chart with the levels option
iex> Expresso.Element.Chart.Sankey.titles?(%{kind: :alluvial, levels: ["2020", "2026"]})
true
iex> Expresso.Element.Chart.Sankey.titles?(%{kind: :sankey, levels: ["from", "to"]})
false