Visualizing Structures¶
Introduction¶
A data structure is easier to understand when it can be seen. print()
on a structure gives its repr, which says what it is but rarely
how it is organized. The visualizer answers that second question: it
draws the links of a list, the shape of a tree, the edges of a graph.
from sds.utils.visualizer import to_text, to_mermaid, to_dot, render, save_image
Concepts¶
From Structure to Drawing¶
Every structure has a natural picture: a list is a chain, a tree is a hierarchy hanging from its root, a graph is a set of nodes joined by edges. The visualizer reduces each structure to one of these three shapes, a view, and then writes the view in the requested format.
graph LR
L[Linear structure] --> C[chain view]
T[Tree] --> H[tree view]
G[Graph] --> N[graph view]
C --> F{format}
H --> F
N --> F
F --> TX[text]
F --> MM[Mermaid]
F --> DT[DOT]
F --> IM[image]
style C fill:#3498db,color:#fff
style H fill:#3498db,color:#fff
style N fill:#3498db,color:#fff
The view is read through the public interface of the structure only:
iteration for linear structures, root and children() for trees,
nodes() and edges() for graphs. The visualizer never looks at
private attributes, so it shows what the structure is, not how one
particular class happens to store it. A red-black tree, for instance,
keeps sentinel leaves internally; its children() hides them, and so
does its drawing.
Why Free Functions?¶
The structures have no draw() or to_mermaid() method. Presentation
is kept out of the classes for the same reason algorithms are (see
Algorithms): a structure models a container, and adding
every possible output format to it would make each class larger and tie it
to formats that have nothing to do with storing data. As free functions,
the visualizer is written once and serves every structure.
Determinism¶
The same structure always gives the same output. Linear structures are read in iteration order, trees from the root with children in their stored order, graphs in node and edge insertion order. This matters in practice:
an output can be compared with an expected string in a test or a doctest;
a diagram generated for the documentation does not change from one build to the next unless the structure does.
The Formats¶
Format |
Best for |
Function |
Needs |
|---|---|---|---|
Text |
Terminals, logs, doctests |
nothing |
|
Mermaid |
Sphinx, Markdown, GitHub and GitLab pages |
nothing |
|
DOT |
Graphviz, large graphs |
|
Graphviz to render the source |
Image |
Reports, slides, notebooks |
|
render(structure, fmt) picks one of the three text formats by name;
the pysds command uses it for its --format option.
Linear Structures¶
A linear structure renders as a chain in iteration order. Where the ends have a meaning, they are named.
from sds.linear import CircularLinkedList, LinkedList, PriorityQueue, Queue, Stack
from sds.utils.visualizer import to_mermaid, to_text
stack, queue, ring = Stack(), Queue(), CircularLinkedList()
for item in (3, 1, 2):
stack.push(item)
queue.enqueue(item)
ring.append(item)
Structure |
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
The stack shows its last pushed item first, because that is where it will be popped; the priority queue shows its items in the order it will serve them.
In Mermaid, a named end becomes a rounded node and a circular list loops back to its first item:
flowchart LR
n0["1"]
n1["2"]
n2["3"]
n0 --> n1
n1 --> n2
n2 --> n0
flowchart LR
n0["1"]
n1["2"]
n2["3"]
n0 --> n1
n1 --> n2
n2 --> n0
Trees¶
Binary Trees¶
A binary tree renders one node per line; each child is tagged L or
R so that a single child keeps its side.
from sds.tree import BinarySearchTree, RedBlackTree
bst = BinarySearchTree()
for key in (50, 30, 70, 20, 40):
bst.insert(key)
print(to_text(bst))
50
├── L: 30
│ ├── L: 20
│ └── R: 40
└── R: 70
The same tree in Mermaid, top-down:
flowchart TD
n0["50"]
n1["30"]
n2["20"]
n1 -->|L| n2
n3["40"]
n1 -->|R| n3
n0 -->|L| n1
n4["70"]
n0 -->|R| n4
A red-black tree built from 1 to 5 shows its real shape, without its sentinel leaves:
2
├── L: 1
└── R: 4
├── L: 3
└── R: 5
General Trees¶
Children of a general tree appear in the order they were added:
from sds.tree import Tree
tree = Tree()
root = tree.add_child("root")
src = tree.add_child("src", root)
tree.add_child("main.py", src)
tree.add_child("README", root)
print(to_text(tree))
root
├── src
│ └── main.py
└── README
B-Trees¶
A B-tree node holds several keys; each node shows them between brackets. With minimum degree 2, after inserting 1 to 7:
[2 | 4]
├── [1]
├── [3]
└── [5 | 6 | 7]
Mermaid escapes the | separator itself (#124;), so the label is
safe:
flowchart TD
n0["[2 #124; 4]"]
n1["[1]"]
n0 --> n1
n2["[3]"]
n0 --> n2
n3["[5 #124; 6 #124; 7]"]
n0 --> n3
Graphs¶
A graph renders as an adjacency list: one line per node, in insertion
order, followed by its neighbors. -> marks a directed graph, -- an
undirected one, and weights appear in parentheses.
from sds.graph import GraphNode, WeightedEdge, WeightedGraph
paris, lyon, lille, nantes = (
GraphNode(name, key)
for name, key in [("Paris", "par"), ("Lyon", "lyo"),
("Lille", "lil"), ("Nantes", "nan")]
)
roads = WeightedGraph()
for city in (paris, lyon, lille, nantes):
roads.add_node(city)
roads.add_edge(WeightedEdge(paris, lyon, 465))
roads.add_edge(WeightedEdge(paris, lille, 225))
roads.add_edge(WeightedEdge(paris, nantes, 385))
print(to_text(roads))
Paris -- Lyon (465.0), Lille (225.0), Nantes (385.0)
Lyon -- Paris (465.0)
Lille -- Paris (225.0)
Nantes -- Paris (385.0)
An undirected edge is listed under both of its ends: it can be read from either.
Mermaid draws the same graph left to right:
flowchart LR
n0["Paris"]
n1["Lyon"]
n2["Lille"]
n3["Nantes"]
n0 ---|465.0| n1
n0 ---|225.0| n2
n0 ---|385.0| n3
DOT, the input language of Graphviz, is offered for graphs only. Nodes are
identified by their id, so two cities with the same name stay
distinct:
with open("roads.dot", "w") as stream:
stream.write(to_dot(roads))
graph {
"par" [label="Paris"];
"lyo" [label="Lyon"];
"lil" [label="Lille"];
"nan" [label="Nantes"];
"par" -- "lyo" [label="465.0"];
"par" -- "lil" [label="225.0"];
"par" -- "nan" [label="385.0"];
}
Graphviz turns it into an image with dot -Tsvg roads.dot -o roads.svg.
Images¶
save_image() draws any supported structure with matplotlib and writes
the file; the extension chooses the format.
pip install "pysds-tools[image]"
from sds.utils.visualizer import save_image
save_image(bst, "bst.png")
save_image(roads, "roads.svg")
The visualizer places the nodes itself, with a classic layout for each shape:
Shape |
Placement |
|---|---|
Chain |
One row, left to right; a circular list bends its last link back |
Binary tree |
Each node in its own column, in in-order position, one row per level: a node always lies between its left and right subtrees |
Other trees |
Each parent centred above its children |
Graph |
Nodes evenly spaced on a circle, from the top, clockwise in insertion order; when two edges join the same nodes in opposite directions, both are bent so neither hides the other |
These layouts are simple on purpose: they are predictable and need no tuning. For large graphs, where crossings become a problem, export DOT and let Graphviz compute the layout.
Labels¶
By default a node shows its data and an item shows itself. The label
keyword accepted by every function changes that without touching the
structure:
print(to_text(roads, label=lambda node: node.id))
par -- lyo (465.0), lil (225.0), nan (385.0)
lyo -- par (465.0)
lil -- par (225.0)
nan -- par (385.0)
Structure |
Argument |
|---|---|
Linear structures |
each item |
Binary trees, general trees, graphs |
each node (use |
B-trees |
each key |
Typical uses are shortening long data, showing an attribute of stored objects, or adding a computed value such as a node degree.
Visualizations in Practice¶
In Tests and Doctests¶
The text format is deterministic and dependency-free, which makes it a compact way to assert the shape of a structure:
def test_avl_rotates_on_sorted_input():
tree = AVLTree()
for key in (1, 2, 3):
tree.insert(key)
assert to_text(tree) == "2\n├── L: 1\n└── R: 3"
In Documentation¶
Mermaid source can go straight into a page. In Sphinx with
sphinxcontrib-mermaid, wrap it in a .. mermaid:: directive; in
Markdown rendered by GitHub or GitLab, in a mermaid code fence:
with open("bst.md", "w") as stream:
stream.write("```mermaid\n" + to_mermaid(bst) + "\n```\n")
Every diagram of this page was produced that way.
In Notebooks¶
Save to SVG and display the file, or print the text form directly in a cell; both stay readable for structures of a few dozen nodes.
Best Practices¶
✅ Use to_text while exploring and testing; switch to Mermaid or an
image only when the result is meant for someone else.
✅ Give graph nodes meaningful id values: DOT uses them as node names,
and label=lambda n: n.id shows them.
✅ Generate documentation diagrams from code rather than drawing them by hand: they then always match the library’s behavior.
❌ Do not rely on repr() to inspect a structure’s shape: it summarizes,
it does not draw.
❌ Do not expect the circle layout of save_image to stay readable for
graphs of hundreds of nodes; use DOT and Graphviz there.
References¶
R. Tamassia (ed.), Handbook of Graph Drawing and Visualization, CRC Press, 2013; chapter 5, A. Rusu, Tree Drawing Algorithms (freely available chapter) — https://cs.brown.edu/people/rtamassi/gdhandbook/chapters/trees.pdf
Mermaid, Flowchart syntax — https://mermaid.js.org/syntax/flowchart.html
Graphviz, The DOT Language — https://graphviz.org/doc/info/lang.html