# `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
```python
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_width` → `stroke-width`) |
| `rgb(r, g, b) -> str` | Build `rgb(r,g,b)` color string |
### Examples
```python
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))
#
#
# --- Rect with fill ---
el = rect(10, 20, 30, 40, fill="red")
print(str(el))
#
# --- Circle ---
c = circle(50, 50, 10, fill="#1f77b4", stroke="black")
#
# --- Line ---
l = line(0, 0, 100, 100, stroke="black", stroke_width=2)
#
# --- Polyline (connected points) ---
pl = polyline([(0, 0), (10, 20), (20, 5)], stroke="blue", fill="none")
#
# --- Text ---
t = text(10, 20, "Hello, World!", font_size=14, font_family="sans-serif")
# Hello, World!
# --- 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"))
#
#
#
#
# --- 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 `` 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 `<`, etc. |
---
## Scales
```python
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
```python
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
```python
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
```python
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 | For multiple data series, plot functions add elements to shared axes; for multiple separate axes, create separate figures or compose SVG manually. |
| **Axis labels with special characters** | Labels are XML-escaped in output; e.g., `xlabel="x < 5"` renders as `x < 5` in SVG | Prevents invalid XML. |
| **Grid on/off** | Grid lines are always drawn (light gray, stroke="#ddd") | There is no `grid=False` parameter; to disable, modify the SVG output or set grid stroke to "none" manually. |
| **Very long axis labels or titles** | Text may overflow the canvas; no auto-wrapping | Manage label length manually or adjust figure dimensions. |
---
## Plot Functions
```python
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
```python
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
```python
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
```python
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
```python
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. |