Extending plotlet
Adding a custom plot type is a 3-step recipe: write three small functions,
bundle them into an ArtistSpec, hand it to add_artist(...). After that,
c.add_<your_name>(...) Just Works on any Chart — autoscaling, gridlines,
color cycling, and the legend integrate for free. No edits to plotlet internals,
no monkey-patching. Custom artists live in your project or in the separate
plotlet-extensions
package — both are good references.
The three steps
import plotlet as pt
from plotlet import aes
from plotlet.utils import to_list, pack_opts
from plotlet.draw import segment, circle
# 1. record(...) -> dict
# Turn c.add_<name>(...) into the artist dict stored in Chart._calls.
# Pure data — no scales yet. Write an EXPLICIT signature: the
# parameter list IS your artist's kwarg vocabulary, so Python itself
# rejects a typo like `c.add_lollipop(..., widht=2)` at render, and
# `c.lollipop?` shows the real parameters. `data=` first lets a caller
# pass the table positionally (`c.add_lollipop(df, aes(x=, y=))`). The
# user's `aes(...)` (the reserved `mapping` kwarg) is merged into
# plain kwargs as aes-tagged strings before record() runs, so
# `data[x]` indexes the column as before. For a kwarg that may be a
# column or a literal, `resolve_aes(data, value)` classifies:
# aes-tagged → column values, anything else → literal. Style kwargs
# the record doesn't consume go into `opts` via `pack_opts` (which
# drops the None defaults, so the draw side's `.get(k, default)` still
# falls through to your defaults).
def my_record(data=None, x=None, y=None, color=None, linewidth=None,
label=None):
if data is None or x is None or y is None:
raise TypeError("lollipop requires data=, x=, y=.")
return {"type": "lollipop",
"xs": to_list(data[x]),
"ys": to_list(data[y]),
"opts": pack_opts(color=color, linewidth=linewidth, label=label)}
# 2. xdomain(a) / ydomain(a) -> Iterable[float] | None
# Yield every value that should participate in axis autoscaling. Return
# None if your artist doesn't constrain that axis (e.g. axhline ignores x).
def my_xdomain(a): return a["xs"]
def my_ydomain(a): return list(a["ys"]) + [0] # always include 0 for stems
# 3. draw(a, ctx) -> str
# Emit the SVG fragment. ctx carries scales, dimensions, color, defaults.
# Read style back from opts with a default — an unset kwarg simply
# isn't in opts, so `.get(k, default)` supplies it.
def my_draw(a, ctx):
color = a["opts"].get("color") or ctx.color
width = a["opts"].get("linewidth", 1.5)
out = []
y0 = ctx.y_scale(0)
for x, y in zip(a["xs"], a["ys"]):
px, py = ctx.x_scale(x), ctx.y_scale(y)
out.append(segment(px, y0, px, py, color=color, width=width))
out.append(circle(px, py, 5, fill=color))
return "".join(out)
pt.add_artist(pt.ArtistSpec(
name="lollipop",
record=my_record, xdomain=my_xdomain, ydomain=my_ydomain, draw=my_draw,
))
df = {"x": [1, 2, 3, 4, 5], "y": [3, 7, 2, 9, 4]}
c = pt.chart()
c.add_lollipop(df, aes(x="x", y="y"), label="A")
c.title("Lollipop chart").gridlines(True).save_svg("out.svg")
Worked example: lollipop.py
in the plotlet-extensions package — basic artist plus an optional
legend_entries so the legend entry looks like a tiny lollipop. Every
extension in plotlet-extensions
is a working reference; skim a couple before writing your own.
When your functions run
None of them run when the user calls c.add_lollipop(...) — that only
appends the raw call to the journal. Everything executes inside
to_svg(), split across the two render passes
(ARCHITECTURE.md):
| Hook | Pass | What exists at that point |
|---|---|---|
record |
resolve | Nothing but the raw kwargs. No scales, no pixels — its output is what domains and scales get built from. Re-runs on every render (re-renders replay the journal), so it must return the same result every time and touch nothing outside itself. |
xdomain / ydomain, axis_order, frame_defaults |
resolve | The record dict from record. Still data-space only. |
draw, legend_entries, data_attrs |
emit | Everything is already decided: ctx carries the final pixel scales, panel size, and resolved color. Turn the record into SVG text here; do not make any new decisions. |
Two things follow from this: an error raised in record appears when the
chart is rendered, not on the line where the user typed the call; and
rendering a ResolvedIR twice only re-runs the emit column — record
already ran once, at resolve.
Drawing helpers — plotlet.draw
Pixel-coordinate primitives. Compose them in draw with "".join(...); the
framework wraps your fragment in a <g> that carries data-plotlet-* attrs.
| Helper | What it emits |
|---|---|
segment(x1, y1, x2, y2, *, color, width, dash, alpha) |
One <line>. |
rect(x, y, w, h, *, fill, stroke, stroke_width, alpha, fill_alpha, stroke_alpha) |
One <rect>. fill=None (default) → outline-only. |
circle(cx, cy, r, *, fill, stroke, stroke_width, alpha, fill_alpha, stroke_alpha) |
One <circle>. |
path(d, *, fill, stroke, stroke_width, dash, alpha, fill_alpha, stroke_alpha) |
<path> with arbitrary d. Use when polyline / polygon aren't shaped right. |
polyline(points, *, color, width, dash, alpha) |
Stroked polyline through [(x, y), …]. No fill. |
polygon(points, *, fill, stroke, stroke_width, alpha, fill_alpha, stroke_alpha) |
Closed shape (auto-trailing Z) through [(x, y), …]. |
errorbar_v(x, y_lo, y_hi, *, capsize, color, width, alpha) |
Vertical bar with two caps; capsize=0 drops the caps. |
errorbar_h(y, x_lo, x_hi, *, capsize, color, width, alpha) |
Horizontal bar with two caps. |
marker(kind, x, y, size, color, alpha, edgecolor=, edgewidth=) |
One of "o" "s" "^" "v" "<" ">" "x" "+" "*" "D" "h" at pixel (x, y); edgecolor= outlines the filled kinds. |
text_path(s, x, y, size, anchor, color, fontstyle=, fontweight=) |
Text as glyph paths (font-independent across machines); fontstyle="italic" / fontweight="bold" select the active family's real variant faces. |
arc(x0, y0, x1, y1, *, height, **path_kwargs) |
Half-ellipse arc between two points; height sets the apex distance (sign picks the side). |
split_rect(x, y, w, h, n, i, *, ...) / split_pie(x, y, w, h, n, i, *, ...) |
Sector i of a rect perimeter / pie divided into n sectors — glyphs for set-membership marks. |
dash= accepts the short codes ("--", ":", "-.") or a raw SVG
dasharray ("6,3"). fill_alpha / stroke_alpha override alpha per
channel — leave them None for the lean single-opacity path.
When no helper fits — Bézier curves, <text>, <image>, gradients — drop
to a raw f-string. It's just SVG.
Dense marks — raster fast-path
One SVG node per data point stops scaling around a million marks: resvg
(our only rasterizer, for save_png) refuses documents past a node
limit, and the SVG itself balloons. If your artist draws one mark per row
(a point cloud), splat the marks into one <image> instead:
| Helper | Use |
|---|---|
should_rasterize(n, opts.get("rasterize")) |
Decide vector vs raster — explicit rasterize= kwarg wins, else auto above dense_threshold (the one spec number for "too many marks for per-mark vector output", shared with line decimation). |
splat_disks(px, py, radius, rgb, alpha, iw, ih) |
One solid-color disk cloud → one <image>. px/py are panel pixels; rgb from parse_rgb(color). |
splat_disks_by_color(marks, radius, alpha, iw, ih) |
marks = (color, px, py) tuples; splats one <image> per color (for category artists). |
splat_ticks(pos, lo, hi, width, rgb, alpha, iw, ih, *, vertical) |
Axis-aligned ticks (rug). |
raster_declined(artist, n, reason, opts.get("rasterize")) |
Call when you decided to rasterize but can't — raises on explicit rasterize=True, warns in auto mode. Never decline silently. |
Gate it on ctx.warp is None and a single-solid-color style; fall back to
the vector loop otherwise (through raster_declined, so the user hears
about it). Overlapping same-color marks composite exactly as stacked
vector marks, and the output stays byte-identical. See
draw/_raster.py for the full rationale.
Dense lines are a different problem: a whole series is already one
<path> node, so the node limit never triggers — the d-string just
balloons. Don't splat strokes (a path paints its color once across
self-overlaps; per-mark alpha stacking would darken them). Decimate
vertices instead with should_simplify / simplify_path_pts — see
draw/_simplify.py.
ArtistSpec fields
The authoritative field list, signatures, and defaults are the
ArtistSpec dataclass in registry.py —
name, record, and draw are required; everything else is an opt-in
behavior. What each opt-in is for:
| Field | When you need it |
|---|---|
accepts_data_positional |
Default True — enables the c.add_<artist>(df, aes(x=, y=)) sugar that hoists a single positional arg into kw["data"]. Set False for matrix-input or single-primary-input artists (heatmap, imshow, axhline, annotate) so their args[0] doesn't get swallowed. Multi-positional artists (rect, polygon, …) are unaffected either way because the sugar only triggers on len(args) == 1. |
xdomain / ydomain |
Your artist's data should drive axis limits. Return None for decorative artists (axhline, axvline). |
xdomain_log / ydomain_log |
Domain contribution under a log scale when it must differ from the plain hook — e.g. a CI band whose non-positive bounds would poison a log domain. None (default) → the plain hook serves every scale kind. |
layer |
"background" for fills (drawn first), "foreground" for reference lines (drawn last). Default "data". |
uses_color_cycle |
Set False for artists that pick their own color (reflines, image artists). Set default_color for the fallback. |
legend_entries |
Return the legend entries this artist contributes (zero or more). Each entry is {"label": str, "color": str} plus optional "alpha", "group" (clusters entries under one header), and "paint" — paint(a, ctx, x0, y_mid) -> svg_fragment overrides the default rect swatch. Most one-series-per-call artists return zero or one entry depending on whether label= was set. |
legend_gradient |
For artists with a continuous color mapping. Returns {"kind": "continuous", "cmap": ..., "vmin": ..., "vmax": ...}. |
data_attrs |
AI-readable type-specific attrs. Keys land on the artist's <g> as data-plotlet-<key>. Common attrs (type, index, label, color) are automatic — see AI_ATTRS.md. |
flips_y_axis |
Return True when this artist needs the y-axis inverted (top → bottom). Used by imshow / heatmap so row 0 sits at the top. |
tight_domain |
When True, the artist's xdomain / ydomain are used as-is — no expand padding added. For artists whose extents are exact (image bounds, raster cell edges). |
force_zero_x / force_zero_y |
Anchor that axis to zero: if the artist contributes to autoscaling and data lo > 0, push lo down to 0 (and suppress that side's expand). Built-in bar and hist set force_zero_y=True. May be a callable (a) -> bool so e.g. a bar with orientation='h' forces zero on x instead. |
axis_order |
Contribute a canonical order for a categorical axis. Returns {"x": [...]} / {"y": [...]}. Use when ordering is load-bearing (dendrogram leaves). User's explicit xscale("category", order=...) still wins. |
frame_defaults |
Return a list of (call_name, args, kwargs) recorded before your artist. Use for strong defaults (e.g. dendrogram hides all spines). User calls after c.add_<your_artist>() still win. |
crosses_sectors |
Set True for artists whose geometry spans sector boundaries (chord_links, chord_ribbon). Suppresses the inter-sector divider walls while the artist is active — walls cutting through a cross-sector curve read as a layering bug. Sector labels still render. |
Coord support lives with the coord — see the "Coordinate classes" section.
RenderContext
Second argument to draw and to legend-entry paint callbacks. Bundles
render state so call sites stay short.
| Field | Notes |
|---|---|
x_scale, y_scale |
scale(value) -> pixel. On a categorical axis, returns the band center; .bandwidth is also available (bars subtract bandwidth/2 for the rect's left edge). |
iw, ih |
Inner figure width / height in pixels (after margins). |
color |
The resolved color for this artist. None if uses_color_cycle=False and no default_color (e.g. imshow). |
defaults |
The spec.json defaults dict (linewidth, markersize, scatter_s, …). Use these instead of literals. |
dash |
Linestyle codes → SVG stroke-dasharray strings. The draw.* helpers already accept the codes directly via dash=. |
project |
Set by c.coordinate(...) for non-affine coords; None for Cartesian and affine coords. project(t, r) -> (px, py) maps data-space directly to canvas pixels — use it when you want to draw straight in the target coord (e.g. radial line from r=0 to r=1 at angle t). |
warp |
Set by c.coordinate(...) for non-affine coords; None for Cartesian and affine coords. warp(x_px, y_px) -> (px, py) remaps a pre-warp Cartesian pixel through the coord. Artists opted-in via declare_coord_support pass this to draw.* helpers via project= so segments subdivide, polygons curve, and markers land correctly. |
Artist-dict conventions
Whatever you return from record(...) ends up in Chart._calls
and is passed to xdomain / ydomain / draw as a. Two conventions:
- Always set
"type"to your artist name — used for the registry lookup and small special-cases (force_zerofor bar/hist y, hist pre-binning). - Always set
"opts"to the kwargs dict — color resolution, label collection,linestyle,linewidth,alpha,markeretc. all live there.
Keys starting with _ (e.g. _bins, _data, _color) are conventionally
"computed during render, used during draw" — see how imshow and hist
stash pre-processed data in artists/.
Respect deferred rendering: record runs early, draw runs at to_svg()
time. Don't compute scales or colors in record — they don't exist yet.
Tree-shaped artists
If you're writing a dendrogram variant (radial, icicle, curved branches,
sunburst, …), don't reach for scipy.cluster.hierarchy directly — the
plotlet.cluster module exposes the full layout pipeline so a third-party
tree artist is purely a renderer. Canonical example: the curved_tree
test fixture (tests/_curved_tree.py), a
curved-branch renderer built entirely on the public cluster API.
The helpers below live in the plotlet.cluster module, reachable either
as pt.cluster.<helper> or via from plotlet.cluster import layout_tree, fit_parent, .... The two driver functions are top-level: pt.linkage
and pt.linkage_split.
| Public helper | Use |
|---|---|
pt.linkage(data, labels=, method=, metric=) |
One scipy.linkage → SplitTree (one block). |
pt.linkage_split(data, split=, labels=, …) |
Two-level cluster (within-block + centroid between-block) → multi-block SplitTree. |
build_tree(data, split, tree=, linkage_matrix=, method=, metric=, labels=) |
Standard input dispatch over tree= / linkage_matrix= / data; returns (SplitTree, had_labels). Forward your tree artist's matching parameters straight into it. |
layout_tree(tree) |
SplitTree → (blocks, offsets, final_labels) ready for drawing. Per-block scipy.dendrogram + pooled height normalize. |
layout_parent(tree) |
The optional centroid tree's (icoord, dcoord, leaves), or None for single-block trees. |
fit_parent(blocks, parent_layout, parent_frac, gap_frac=) |
Shrinks per-block dcoords + drops parent leaves to each block's apex so both fit in one panel. |
leaf_position(scale, labels, disp) |
Float leaf-position → pixel; gap-aware on split scales. |
block_apex_centers(scale, labels, offsets, blocks) |
x-center of each block's topmost merge bar — where parent leaves should land. |
parent_leaf_px(midpoints, x) |
Interpolate between block midpoints for fractional parent-tree x values. |
tree_frame_defaults(kw) |
Standard frame_defaults boilerplate for tree artists: spines off, hide height-axis ticks, root-side expand. (For block gap whitespace, declare c.sectors(...) on the panel.) |
A new tree variant is then ~3 callbacks (record / draw / axis_order),
each a thin wrapper around these helpers — the clustering and layout
are not your concern. The visible API stays uniform: c.add_<artist>(data, labels=, orientation=, clusters=, parent=, ...).
Coordinate classes
Custom (t, r) → pixel projections — rings, polar discs, future spirals —
live under their own protocol. See COORDINATES.md for
the model, the hook table, and a minimal worked example.