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.ElementTree and 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 Figure and 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)
  1. Raw data coordinates are passed to a Scale object (e.g., LinearScale) that maps domain values to pixel positions.

  2. Scaled coordinates are used to construct SVG elements (circles, rectangles, polylines, text labels).

  3. Elements are attached to a Figure that manages margins, axes, ticks, and gridlines.

  4. Calling to_svg() or save() 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

SVG(width, height, **attrs)

Top-level SVG document (adds xmlns, width, height)

.element(tag, **attrs) -> Element

Add a child element

.save(path)

Write XML to file

str(svg)

Render as XML string

Element(tag, **attrs)

Generic SVG element with optional children

.set(attr, value) -> self

Set (or overwrite) an attribute; underscores become hyphens

.append(child) -> child

Append a child element; returns the child for chaining

rect(x, y, w, h, **style)

Rectangle at (x,y) with dimensions

circle(cx, cy, r, **style)

Circle centered at (cx, cy)

line(x1, y1, x2, y2, **style)

Line segment

polyline(points, **style)

Connected line segments (list of (x,y) tuples)

polygon(points, **style)

Filled polygon (list of (x,y) tuples)

text(x, y, content, **style)

Text label at (x,y)

path(d, **style)

SVG path (arbitrary d string)

g(**style)

Group container (for applying shared styles or transforms)

style_str(**kwargs) -> str

Build CSS style string; underscores → hyphens (e.g., stroke_widthstroke-width)

rgb(r, g, b) -> str

Build rgb(r,g,b) color string

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

str(SVG(100, 100)) produces a valid <svg>...</svg> with no children

Nested elements

Recursive __str__ indentation works for arbitrary depth

CSS styles with underscores

style_str(fill="red", stroke_width=2)fill:red;stroke-width:2 (underscore auto-converted to hyphen)

Float values

Rounded to 4 significant figures via _fmt; trailing decimal points removed

XML escaping

Text content is XML-escaped; < becomes &lt;, etc.


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

LinearScale(domain=(0,1), range_=(0,100))

Linear mapping: y = r0 + (x - d0) / (d1 - d0) * (r1 - r0)

.call(x) -> float

Map data → pixel

.invert(y) -> float

Map pixel → data

.ticks(n=5) -> list

Nice tick values (human-readable multiples)

LogScale(domain, range_)

Logarithmic mapping: uses log(x) internally

BandScale(domain, range_, padding=0.1)

Discrete band scale for bar charts

.call(label) -> float

Band center position

.bandwidth() -> float

Band width (step × (1 - padding))

auto_scale(data, scale_type)

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)

__call__ returns (r0 + r1) / 2 (midpoint); ticks returns [d0]; invert still works

Prevents division by zero. All data points map to the center of the range.

LogScale with non-positive domain

__call__ clips x ≤ 0 to 1e-15 before taking log; domain values ≤ 0 silently set to 0 in their log representation

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

_log_d0 set to 0 if d0 ≤ 0; the mapping will be distorted

Avoid: always use positive domain for LogScale.

BandScale with empty domain

bandwidth() returns 0; call(label) returns r0 if label not in empty list; ticks returns []

No bands to display.

auto_scale with empty data

Returns LinearScale((0, 1), (0, 100))

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(width=800, height=500)

Figure canvas

.add_axes(x_scale, y_scale, xlabel, ylabel, title) -> Axes

Add coordinate axes

.to_svg() -> str

Render to SVG XML

.save(path)

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

add_axes replaces _axes; only the last set is rendered

Axis labels with special characters

Labels are XML-escaped in output; e.g., xlabel="x < 5" renders as x &lt; 5 in SVG

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

plot(x, y, fig=None, **style) -> (Figure, Element)

Line chart (polyline). Default: blue stroke, width=2, no fill.

scatter(x, y, fig=None, r=4, **style) -> (Figure, list)

Scatter plot (circles). Default: blue fill, white stroke.

bar(labels, values, fig=None, **style) -> (Figure, list)

Bar chart (rects). Default: dark blue fill.

hist(data, bins=10, fig=None, **style) -> (Figure, list)

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

GraphLayout(nodes, edges)

Force-directed layout engine

.spring(width, height, iterations, k, repulsion)

Run spring-electric simulation

draw_graph(nodes, edges, node_labels, width, height, node_r, font_size, layout) -> str

Render graph as SVG

How the Layout Works

  1. Nodes are placed at random positions.

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

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