# `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. |