Expresso.Element.Chart.Sankey (expresso v0.1.0)

Copy Markdown View Source

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:

  1. 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 column of a node entity sets the column of its node.
  2. 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.
  3. The nodes of a column keep the order of the data, or the order of their node entity. At each node, the links have the order of their other ends, from the top, so few links cross.
  4. 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

key()

@type key() :: String.t() | {non_neg_integer(), String.t()}

A node: its name, or its column from 0 and its name in an alluvial chart

link()

@type link() :: %{from: key(), to: key(), value: number()}

A link: two nodes and a value

Functions

back_links(links)

@spec back_links([%{from: key(), to: key()}]) :: MapSet.t({key(), key()})

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"}])

cell(assigns, name)

@spec cell(map(), key()) :: String.t()

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.

check(categories, values, separator, alluvial \\ false)

@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

columns(links, pinned \\ %{})

@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}

inner_block(name, list)

(macro)

label_place(node)

@spec label_place(map()) :: keyword()

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"]

label_text(node, bool)

@spec label_text(map(), boolean()) :: String.t()

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"

labeled(nodes)

@spec labeled([map()]) :: [map()]

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]

layout(links, top \\ 8, settings \\ %{})

@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

  1. The function returns the nodes, with their boxes, colors and the number of their first series, and the links, with their paths. top is the top of the box, and settings holds the Expresso.Element.Node of each node with a node entity.

layout_frames(frames, top, settings \\ %{})

@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.

links(categories, values, separator, alluvial \\ false)

@spec links([String.t() | [String.t()]], [number()], String.t(), boolean()) :: [
  link()
]

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}]

name(name)

@spec name(key()) :: String.t()

Return the name of a node

iex> Expresso.Element.Chart.Sankey.name("Gas")
"Gas"
iex> Expresso.Element.Chart.Sankey.name({2, "Gas"})
"Gas"

node_width()

@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

path(nodes, separator)

@spec path(String.t() | [String.t()] | [key()], String.t()) :: [key()]

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"]

pinned(settings)

@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}

render(assigns)

@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.

render_slot(slot, arg)

(macro)

ribbon(x0, y0, x1, y1, height)

@spec ribbon(number(), number(), number(), number(), number()) :: String.t()

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"

table(assigns)

@spec table(map()) :: %{headers: [String.t()], rows: [[String.t()]]}

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

titles(assigns)

@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.

titles?(arg1)

@spec titles?(map()) :: boolean()

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