latpy.viz — Visualization
Pure stdlib SVG-based visualization with zero external dependencies. Outputs standard SVG XML that can be viewed in any browser, embedded in HTML, or opened in vector graphics editors (Illustrator, Inkscape, etc.).
Why SVG?
Resolution-independent: SVG is a vector format — charts look sharp at any zoom level.
No external dependencies: Everything is built from Python stdlib
xml.etree.ElementTreeand string formatting. No matplotlib, no Pillow, no Cairo.Inspectable: SVG files are plain text XML — you can open them in a text editor, search them, diff them, or embed them directly into web pages.
Customizable after creation: Every plot function returns the
Figureand SVG elements, so you can modify colors, add annotations, or compose multiple plots on one figure.
Rendering Pipeline
Data → Scale (data→pixel) → SVG Elements → File (XML string)
Raw data coordinates are passed to a Scale object (e.g.,
LinearScale) that maps domain values to pixel positions.Scaled coordinates are used to construct SVG elements (circles, rectangles, polylines, text labels).
Elements are attached to a Figure that manages margins, axes, ticks, and gridlines.
Calling
to_svg()orsave()serializes the entire scene to an SVG XML string (or file).
SVG Primitives
from latpy.viz import SVG, Element, rect, circle, line, polyline, polygon, text, path, g, style_str, rgb
Low-level building blocks for constructing arbitrary SVG documents.
Signature |
Description |
|---|---|
|
Top-level SVG document (adds |
|
Add a child element |
|
Write XML to file |
|
Render as XML string |
|
Generic SVG element with optional children |
|
Set (or overwrite) an attribute; underscores become hyphens |
|
Append a child element; returns the child for chaining |
|
Rectangle at (x,y) with dimensions |
|
Circle centered at (cx, cy) |
|
Line segment |
|
Connected line segments (list of (x,y) tuples) |
|
Filled polygon (list of (x,y) tuples) |
|
Text label at (x,y) |
|
SVG path (arbitrary |
|
Group container (for applying shared styles or transforms) |
|
Build CSS style string; underscores → hyphens (e.g., |
|
Build |
Examples
from latpy.viz import SVG, Element, rect, circle, line, polyline, polygon, text, path, g, style_str, rgb
# --- Empty SVG ---
svg = SVG(200, 100)
print(str(svg))
# <?xml version="1.0" encoding="utf-8"?>
# <svg xmlns="http://www.w3.org/2000/svg" width="200" height="100">
# </svg>
# --- Rect with fill ---
el = rect(10, 20, 30, 40, fill="red")
print(str(el))
# <rect x="10" y="20" width="30" height="40" fill="red"/>
# --- Circle ---
c = circle(50, 50, 10, fill="#1f77b4", stroke="black")
# <circle cx="50" cy="50" r="10" fill="#1f77b4" stroke="black"/>
# --- Line ---
l = line(0, 0, 100, 100, stroke="black", stroke_width=2)
# <line x1="0" y1="0" x2="100" y2="100" stroke="black" stroke-width="2"/>
# --- Polyline (connected points) ---
pl = polyline([(0, 0), (10, 20), (20, 5)], stroke="blue", fill="none")
# <polyline points="0,0 10,20 20,5" stroke="blue" fill="none"/>
# --- Text ---
t = text(10, 20, "Hello, World!", font_size=14, font_family="sans-serif")
# <text x="10" y="20" font-size="14" font-family="sans-serif">Hello, World!</text>
# --- Group with transform ---
grp = g(transform="translate(10,10)")
grp.append(circle(0, 0, 5, fill="red"))
grp.append(circle(10, 0, 5, fill="blue"))
# <g transform="translate(10,10)">
# <circle cx="0" cy="0" r="5" fill="red"/>
# <circle cx="10" cy="0" r="5" fill="blue"/>
# </g>
# --- style_str ---
css = style_str(stroke="red", stroke_width=2, fill="none")
print(css) # stroke:red;stroke-width:2;fill:none
# --- rgb ---
print(rgb(255, 127, 0)) # rgb(255,127,0)
# --- Building a complete SVG ---
svg = SVG(400, 300)
rect_bg = svg.element("rect", x=0, y=0, width=400, height=300, fill="#f0f0f0")
c1 = svg.element("circle", cx=200, cy=150, r=80, fill="steelblue", opacity=0.7)
t1 = svg.element("text", x=200, y=160, text_anchor="middle",
font_size=16, font_family="sans-serif", fill="white")
t1._children.append("Hello SVG")
svg.save("output.svg")
# Opens in browser — a blue circle with centered text on a gray background
Edge Cases
Scenario |
Behavior |
|---|---|
Empty SVG |
|
Nested elements |
Recursive |
CSS styles with underscores |
|
Float values |
Rounded to 4 significant figures via |
XML escaping |
Text content is XML-escaped; |
Scales
from latpy.viz import LinearScale, LogScale, BandScale, auto_scale
Scale objects map data coordinates (the domain) to pixel coordinates (the range). This is the core transformation in the rendering pipeline.
Signature |
Description |
|---|---|
|
Linear mapping: |
|
Map data → pixel |
|
Map pixel → data |
|
Nice tick values (human-readable multiples) |
|
Logarithmic mapping: uses |
|
Discrete band scale for bar charts |
|
Band center position |
|
Band width (step × (1 - padding)) |
|
Automatically infer domain from data |
Examples
from latpy.viz import LinearScale, LogScale, BandScale, auto_scale
# --- LinearScale ---
s = LinearScale((0, 10), (0, 100))
print(s(0)) # 0.0
print(s(5)) # 50.0
print(s(10)) # 100.0
print(s.invert(50)) # 5.0
print(s.ticks(5)) # [0, 2, 4, 6, 8, 10] (nice round numbers)
# --- LogScale ---
s_log = LogScale((1, 100), (0, 100))
print(s_log(1)) # 0.0
print(s_log(10)) # 50.0
print(s_log(100)) # 100.0
# --- BandScale ---
s_band = BandScale(["a", "b", "c"], (0, 300), padding=0.1)
print(s_band("a")) # 50.0 (center of first band)
print(s_band("c")) # 250.0 (center of third band)
print(s_band.bandwidth()) # 90.0 ((300 - 0) / 3 * (1 - 0.1) = 100 * 0.9)
# --- auto_scale ---
s_auto = auto_scale([2, 5, 8, 11], "linear")
print(s_auto(2)) # 0.0 (domain auto-set to [2, 11])
print(s_auto(11)) # 100.0
Edge Cases
Scenario |
Behavior |
Rationale |
|---|---|---|
LinearScale with degenerate domain (d0 == d1) |
|
Prevents division by zero. All data points map to the center of the range. |
LogScale with non-positive domain |
|
Logarithm is undefined for x ≤ 0. The clip ensures no crash, but results may be uninterpretable. Best practice: ensure domain is strictly positive. |
LogScale with d0 ≤ 0 |
|
Avoid: always use positive domain for LogScale. |
BandScale with empty domain |
|
No bands to display. |
auto_scale with empty data |
Returns |
No data to infer from; safe defaults. |
auto_scale with all-identical data |
Domain expanded by ±1 around the value |
Ensures a visible range instead of a degenerate domain. |
auto_scale with log + non-positive data |
d_min clamped to 0.1 |
Avoids log(0) or log(negative). |
Figure & Axes
from latpy.viz import Figure, Axes
Figure manages the SVG canvas, margins, and axes. Axes draws tick marks, grid lines, axis labels, and contains plot elements.
The default layout has margins: left=60, top=40, right=20, bottom=60 pixels, with the plot area filling the remainder.
Signature |
Description |
|---|---|
|
Figure canvas |
|
Add coordinate axes |
|
Render to SVG XML |
|
Save to file |
Examples
from latpy.viz import Figure, LinearScale
# --- Basic figure with axes ---
fig = Figure(500, 400)
x_scale = LinearScale((0, 10), (0, 440))
y_scale = LinearScale((0, 10), (360, 0))
fig.add_axes(x_scale, y_scale, xlabel="Time (s)", ylabel="Position (m)", title="Motion")
svg = fig.to_svg()
# Generates an SVG with:
# - White background
# - Bottom and left axis lines
# - 5 x-axis ticks with labels (0, 2, 4, 6, 8, 10)
# - 5 y-axis ticks with labels
# - Light gray grid lines
# - Axis labels ("Time (s)" below x-axis, "Position (m)" rotated on y-axis)
# - Title "Motion" centered above plot area
# --- Save to file ---
fig.save("chart.svg")
Edge Cases
Scenario |
Behavior |
|---|---|
Multiple axes on same figure |
|
Axis labels with special characters |
Labels are XML-escaped in output; e.g., |
Grid on/off |
Grid lines are always drawn (light gray, stroke=”#ddd”) |
Very long axis labels or titles |
Text may overflow the canvas; no auto-wrapping |
Plot Functions
from latpy.viz import plot, scatter, bar, hist
High-level functions that create a Figure (or reuse one), generate data-driven SVG elements, and return (Figure, elements) for further customization.
Pattern: Create → Customize → Save
from latpy.viz import plot, scatter, Figure
# Create a line plot (auto-creates Figure)
fig, line_el = plot([1, 2, 3, 4], [10, 15, 13, 17],
stroke="green", stroke_width=3)
# Add a scatter on the same axes
fig, circles = scatter([1.5, 2.5, 3.5], [12, 14, 16],
fig=fig, fill="red", r=6)
# Modify the figure after creation (add title manually)
fig.add_axes.__func__ # already called; we can access internal state
# Or directly edit the SVG via fig._svg
fig.save("combined.svg")
Signature |
Description |
|---|---|
|
Line chart (polyline). Default: blue stroke, width=2, no fill. |
|
Scatter plot (circles). Default: blue fill, white stroke. |
|
Bar chart (rects). Default: dark blue fill. |
|
Histogram (rects). Default: blue fill. |
Examples
from latpy.viz import plot, scatter, bar, hist
# --- Line plot ---
fig, el = plot([1, 2, 3, 4, 5], [2, 4, 1, 3, 7])
# Renders: a polyline connecting the 5 points in data space (auto-scaled)
# Appearance: blue line on white background with axis ticks 0-8 on y, 1-5 on x
svg = fig.to_svg()
# --- Line plot with custom style ---
fig2, el2 = plot([0, 10], [0, 100], stroke="red", stroke_width=1)
# A red diagonal line from origin to (10, 100)
# --- Scatter plot ---
fig3, circles = scatter([1, 2, 3, 4], [10, 20, 15, 25], r=6, fill="orange")
# 4 orange circles of radius 6 at the given data coordinates
# Axis ranges auto-adjusted with 5% padding
# --- Bar chart ---
fig4, bars = bar(["Apples", "Bananas", "Cherries"], [30, 15, 45])
# 3 vertical bars labeled "Apples", "Bananas", "Cherries"
# BandScale on x-axis, LinearScale on y (0-45 range)
# Each bar is a filled rectangle centered at its band position
# --- Bar chart with negative values ---
fig5, bars = bar(["Q1", "Q2", "Q3", "Q4"], [10, -5, 8, -3])
# Bars extend below the baseline (y=0) for negative values
# The baseline is drawn at y=0, negative bars grow downward
# --- Histogram ---
fig6, bars = hist([1, 1, 2, 3, 3, 3, 4, 5, 5, 5, 5], bins=4)
# 4 bins spanning [1, 5]; counts auto-calculated
# Appearance: 4 rectangles with heights proportional to bin counts
# x-axis labeled "Value", y-axis labeled "Count"
# --- Reuse a figure for multiple plots ---
fig = None
fig, l1 = plot([1,2,3], [1,2,3], fig=fig)
fig, l2 = plot([1,2,3], [3,2,1], fig=fig, stroke="red")
Edge Cases
Scenario |
Behavior |
Visualization |
|---|---|---|
plot with single point |
Polyline with one point degenerates to a dot (no visible line) |
Need at least 2 points for a visible line segment. |
plot with empty data |
Scale degenerates (range 0,0); polyline may be empty |
Edge case; results in a figure with no visible data. |
scatter with all identical points |
All circles overlap at the same pixel position |
Visually indistinguishable from a single point. |
scatter with empty data |
No circles added; empty axes |
Valid but uninformative chart. |
bar with negative values |
Bars extend below y=0 baseline; the rect’s y coordinate is adjusted |
Negative bars clearly show below-axis values. Baseline is always at y=0. |
bar with empty labels |
Returns empty bars list |
No bars to render. |
hist with all values in one bin |
One tall bar, others empty |
All data falls within a single bin; re-bin with different edges. |
hist with bins=0 |
Division by zero or empty bin list |
Guard: use bins >= 1. |
hist with empty data |
Returns (Figure, []) |
No data to histogram. |
All-zero data |
auto_scale expands by ±1, so range becomes [-1, 1] or similar |
Prevents degenerate domain. |
Graph Visualization
from latpy.viz import GraphLayout, draw_graph
Force-directed graph layout and rendering. Uses a spring-electric simulation: edges attract connected nodes (Hooke’s law), all node pairs repel each other (Coulomb’s law).
Signature |
Description |
|---|---|
|
Force-directed layout engine |
|
Run spring-electric simulation |
|
Render graph as SVG |
How the Layout Works
Nodes are placed at random positions.
For each iteration:
Coulomb repulsion pushes every pair of nodes apart (force ∝ 1/d²).
Spring attraction pulls connected nodes together (force ∝ d).
Forces are applied with a damping factor (0.01).
Positions are clamped to canvas bounds.
After simulation, node positions minimize the total energy.
Examples
from latpy.viz import GraphLayout, draw_graph
# --- Simple graph ---
nodes = ["A", "B", "C"]
edges = [("A", "B"), ("B", "C")]
# Layout + draw in one call
svg = draw_graph(nodes, edges, width=400, height=300, node_r=15, font_size=12)
# Renders: 3 circles labeled A, B, C connected by 2 lines
# Nodes are positioned by automatic spring layout
# Each node gets a distinct color from the palette
print(svg) # Full SVG XML string
# --- Custom layout (run simulation separately) ---
layout = GraphLayout(nodes, edges)
layout.spring(width=400, height=400, iterations=200, k=0.2, repulsion=2000)
svg2 = draw_graph(nodes, edges, layout=layout, width=400, height=400)
# Same visual but with more iterations → more settled layout
# --- Graph with display labels ---
nodes2 = ["n1", "n2"]
edges2 = [("n1", "n2")]
svg3 = draw_graph(nodes2, edges2,
node_labels={"n1": "Server A", "n2": "Server B"},
width=300, height=200)
# Circles display "Server A" and "Server B" instead of "n1" and "n2"
Edge Cases
Scenario |
Behavior |
Visualization |
|---|---|---|
Single node |
Layout places it at random position; drawn as a circle with no edges |
A single circle centered somewhere on canvas. |
Disconnected graph (no edges) |
All nodes repel each other; they spread to maximum distance (clamped by bounds) |
Nodes distributed across canvas, no lines between them. |
Self-loop (edge from node to itself) |
Attraction has d=0, adding 1e-10 epsilon; force is k * 1e-10 / 1e-10 ≈ k |
Node is weakly attracted to itself; a line is drawn from the node to itself (visible as a loop-like line at the node position, though it may appear as a dot). |
Empty graph (no nodes) |
Layout is empty; draw_graph generates a blank SVG |
Nothing to render. |
Single edge |
Two nodes attract and stabilize at a balanced distance |
Standard edge visualized. |
Complete graph (all pairs connected) |
Spring forces compete with repulsion; may reach aesthetic layout |
Dense edge visualization; may be cluttered. |
Large number of nodes (20+) |
Force simulation still runs; O(n²) repulsion per iteration |
May be slow for very large graphs. Visual quality depends on iteration count. |