Annotations
Composable chart annotations - reference lines, text, shading, and auto-placed point labels.
Every constructor returns an Altair chart/layer to compose onto a base chart with +:
add_rule (reference lines), add_text (positioned text), add_shade (background
shading), and add_labels (auto-placed point labels with connectors; the pixel placement
engine lives in _placement.py). Statistical annotations (add_comparisons,
add_correlation) live in inference.py.
add_rule
Section titled “add_rule”def add_rule( value: float | list[float], *, axis: str = 'y', label: str | list[str] | None = None, labelPosition: str | None = None, labelAlign: str | None = None, labelOffsetX: int = 0, labelOffsetY: int = 0, color: str | None = None, strokeWidth: float | None = None, strokeDash: bool | list[int] | None = None, opacity: float = 1.0, fontSize: float | None = None, data: pl.DataFrame | Any | None = None,) -> alt.Chart | alt.LayerChart: ...Add one or more horizontal or vertical reference lines to a chart.
Returns a layer that the caller composes with +.
Parameters
value(float | list[float]) - Coordinate(s) on the specified axis.floatorlist[float].axis(str) -"y"(default) — horizontal line(s) at fixed y value(s)."x"— vertical line(s) at fixed x value(s).label(str | list[str] | None) - Optional text label(s). One string per value.labelAlign(str | None) - Where along the line the label is anchored.axis="y":"left"(default),"center", or"right".axis="x":"top"(default),"center", or"bottom".labelPosition(str | None) - Which side of the line the label sits on.axis="y":"top"(default) or"bottom".axis="x":"right"(default) or"left".labelOffsetX(int) - Additional horizontal pixel offset applied to the label. Default0. Positive shifts right, negative shifts left.labelOffsetY(int) - Additional vertical pixel offset applied to the label. Default0. Positive shifts down, negative shifts up.color(str | None) - Line and label color.Noneinherits from the active theme.strokeWidth(float | None) - Line width in pixels.Noneinherits from the active theme.strokeDash(bool | list[int] | None) -None(default) inherits the theme’sdashedRulesetting.Falseforces a solid line.Trueuses the theme’sdashedWidthpattern. A list (e.g.[4, 2]) uses that pattern directly.opacity(float) - Line opacity. Defaults to1.0.fontSize(float | None) - Label font size.Noneinherits from the active theme.data(pl.DataFrame | Any | None) - Facet-safe (datum) mode.None(default) builds the rule from its own small internal dataset — the normal behavior, but incompatible with faceting (Altair requires every layer of a faceted chart to share one data variable). Pass the same DataFrame you gave the base chart to switch to datum mode: the rule then shares that data and is positioned by a constantalt.datuminstead of a sidecar dataset, so(base + add_rule(..., data=df))can be faceted and the line repeats in every panel. Accepts a polars or pandas DataFrame.
Examples
::
# Horizontal line at y=0 chart = base + ds.add_rule(0)
# Facet-safe: pass the same df as the base, then facet df_chart = alt.Chart(df).mark_point().encode(x="x:Q", y="y:Q") faceted = (df_chart + ds.add_rule(5.0, label="Threshold", data=df)).facet("group:N")
# Labeled horizontal line, label above-left by default chart = base + ds.add_rule(5.0, label="Threshold", color="#c0392b")
# Two horizontal lines, labels at the right end chart = base + ds.add_rule( [4.0, 8.0], label=["Lower limit", "Upper limit"], labelAlign="right", color="#c0392b", )
# Vertical line, label at top-right by default chart = base + ds.add_rule(10, axis="x", label="Intervention", color="#c0392b")
# Vertical line, label nudged right and down chart = base + ds.add_rule( 10, axis="x", label="t₀", labelOffsetX=4, labelOffsetY=4 )add_text
Section titled “add_text”def add_text( text: str | list[str], x = None, y = None, *, position: str | None = None, angle: float = 0, align: str | None = None, baseline: str | None = None, offsetX: int = 0, offsetY: int = 0, color: str | None = None, fontSize: float | None = None, fontWeight: str | None = None, fontStyle: str | None = None, font: str | None = None, opacity: float = 1.0, data: pl.DataFrame | Any | None = None,) -> alt.Chart | alt.LayerChart: ...Add one or more text annotations to a chart.
Returns a layer that the caller composes with +.
Parameters
text(str | list[str]) - Annotation string(s). Pass a list to place multiple annotations in one call —xandymust then also be lists of equal length.x- Horizontal coordinate(s). Three forms are accepted: -float/int— data coordinate on a quantitative x axis. Shares the main chart’s x scale automatically. -str— category name on a nominal x axis. Shares the main chart’s band scale, placing the text at the band center. -alt.value(n)— fixed pixel position,npixels from the left edge of the plot area. Use this (orposition) for annotations that should not move with the data. Required whenpositionis not set.y- Vertical coordinate(s). Same three forms asx, measured from the top of the plot area foralt.value(). Required whenpositionis not set.position(str | None) - Named position within the plot area, flush with the axis domain edges. Setsx,y,align, andbaselineautomatically usingalt.value()pixel coordinates derived fromchartWidth/chartHeightin the active theme. Explicitx,y,align, orbaselinearguments override the position value for that parameter. Valid positions (3 × 3 grid): +------------------+--------------------+-------------------+ |"topLeft"|"topCenter"|"topRight"| +------------------+--------------------+-------------------+ |"middleLeft"|"middleCenter"|"middleRight"| +------------------+--------------------+-------------------+ |"bottomLeft"|"bottomCenter"|"bottomRight"| +------------------+--------------------+-------------------+ Whenclosed=TrueoraxisOffset=0in the active theme, a fixed 1 px inset is applied automatically to edge positions so text clears the border or flush axis line.offsetX/offsetYadd on top of this for further fine-tuning:: chart + ds.add_text(“p = 0.003”, position=“topRight”, offsetX=-4, offsetY=4)angle(float) - Rotation in degrees, clockwise. Vega-Lite requires values in [0, 360]; negative values are wrapped automatically. Defaults to0.align(str | None) - Horizontal text anchor:"left"(default),"center", or"right". Overrides the position value when both are set.baseline(str | None) - Vertical text anchor:"top","middle"(default),"bottom", or"alphabetic"."middle"centers the text body on the y coordinate — best for annotations near symbols or rules."alphabetic"sits the reading baseline on y — best when text sits alongside other typeset text. Overrides the position value when both are set.offsetX(int) - Horizontal pixel nudge applied after positioning. Positive shifts right. Useful for inset when usingposition.offsetY(int) - Vertical pixel nudge applied after positioning. Positive shifts down. Useful for inset when usingposition.color(str | None) - Text color.Noneinherits from the active theme’smark_textconfig.fontSize(float | None) - Font size in points.Noneinherits from the active theme.fontWeight(str | None) -"normal","bold", or a numeric CSS weight (100–900).Noneinherits from the active theme.fontStyle(str | None) -"normal"or"italic".Noneinherits from the active theme.font(str | None) - Font family name (e.g."sans-serif","Georgia").Noneinherits from the active theme.opacity(float) - Text opacity. Defaults to1.0.data(pl.DataFrame | Any | None) - Facet-safe (datum) mode.None(default) builds the annotation from its own internal dataset — the normal behavior, but incompatible with faceting. Pass the same DataFrame you gave the base chart to share its data and position the text byalt.datum(data coordinates) /alt.value(pixels), so(base + add_text(..., data=df))can be faceted and the text repeats in every panel. Accepts a polars or pandas DataFrame.
Examples
::
# Annotation at a data coordinate (quantitative x, quantitative y) chart + ds.add_text("Peak", x=10.5, y=2.3)
# Annotation at a group center (nominal x, quantitative y) chart + ds.add_text("n=20", x="Control", y=8.5, baseline="bottom")
# Multiple annotations at data coordinates chart + ds.add_text( ["Low", "High"], x=[1.0, 9.0], y=[0.5, 0.5], align="center" )
# Corner position — top-right, inset 4 px from boundary chart + ds.add_text("ANOVA p < 0.001", position="topRight", offsetX=-4, offsetY=4)
# Bottom-left with explicit font overrides chart + ds.add_text( "FDR < 0.05", position="bottomLeft", offsetX=4, offsetY=-4, fontSize=6, fontStyle="italic", color="#888888", )
# Fixed pixel position via alt.value() passthrough chart + ds.add_text("†", x=alt.value(60), y=alt.value(10))
# Facet-safe: pass the same df as the base, then facet chart + ds.add_text("★", x="B", y=18.0, data=df)add_labels
Section titled “add_labels”def add_labels( df: pl.DataFrame | Any, xCol: str, yCol: str, labelCol: str, *, labels: int | list | None = None, xDomain: tuple[float, float] | None = None, yDomain: tuple[float, float] | None = None, fontSize: float | None = None, color: str | None = None, connector: bool = True, connectorColor: str | None = None, connectorStrokeDash: bool | list[int] = False, connectorGap: float | None = None, alwaysShowConnectors: bool = False,) -> alt.LayerChart: ...Auto-place non-overlapping text labels for a set of points, with connector lines.
Force-directed placement (deterministic - reproducible figures) nudges each label off its
point and away from the others, drawing a thin leader line from each point to its label. Every
requested label is shown (never dropped); in an impossibly dense region labels settle at their
least-overlapping positions. Returns a layer to compose onto the base chart with +.
Label placement is a pixel-space problem solved before Vega renders, so the connectors align
with the points only if the shared scale matches. add_labels handles that itself: the label
layers pin the x/y scale to the data extent rounded outward to nice tick bounds (nice=False,
zero=False, explicit nice domain), so you do NOT need to touch the base chart’s scale - just
compose base + ds.add_labels(df, ...). (This retightens the axes around the data - with
round bounds, but without Vega’s default zero - which is required for alignment.)
Parameters
df(pl.DataFrame | Any) - The plotted data (polars or pandas) - pass the same frame as the base chart. The axis domain is inferred from its full extent, so the connectors line up without you pinning the base scale (the label layers pin it themselves; see below).xCol(str) - Quantitative coordinate columns (must match the base chart’s x / y encodings).yCol(str) - Quantitative coordinate columns (must match the base chart’s x / y encodings).labelCol(str) - Column holding the label text.labels(int | list | None) - Which rows to label.None(default) labels every row; an intnauto-selectsnrows spread evenly across the plot (unbiased - no cherry-picking, deterministic); a list labels only the rows whoselabelColvalue is in it (e.g.labels=["TP53", "EGFR"]). Pass the full plotteddfand letlabelsdo the selecting - the domain is inferred from all ofdf, so selecting a subset never clips the axes.xDomain(tuple[float, float] | None) -(min, max)axis domains, forced onto the shared scale (nice=False,zero=False). Default: the extent of the passeddf’sxCol/yCol, rounded outward to nice tick bounds (d3-style nice, so the axes end on round numbers; filteringdfjust moves the axes with it - always inferred). An explicit value is used exactly as given (no rounding). Pass explicitly only when you want the axes to span a range the passeddfdoes not cover - i.e. the base chart plots more than you handadd_labels(a deliberate subset, or derived positions like cluster centroids whose extent is tighter than the scatter).yDomain(tuple[float, float] | None) -(min, max)axis domains, forced onto the shared scale (nice=False,zero=False). Default: the extent of the passeddf’sxCol/yCol, rounded outward to nice tick bounds (d3-style nice, so the axes end on round numbers; filteringdfjust moves the axes with it - always inferred). An explicit value is used exactly as given (no rounding). Pass explicitly only when you want the axes to span a range the passeddfdoes not cover - i.e. the base chart plots more than you handadd_labels(a deliberate subset, or derived positions like cluster centroids whose extent is tighter than the scatter).fontSize(float | None) - Label font size.None-> the theme’sfontSize(the primary chart font size).color(str | None) - Label text color.None-> inherits the theme’smark_textcolor (darkmode-aware black/white).connector(bool) - Whether to draw the line connecting each point to its label (defaultTrue).connectorColor(str | None) - Connector line color.None-> inherits the theme’smark_rulecolor (darkmode-aware). Connectors otherwise inherit the theme’s rule style (rounded caps,axisWidthstroke, opaque).connectorStrokeDash(bool | list[int]) - Connector dash pattern.False(default) -> solid;True-> the theme’sdashedWidthpattern; a list (e.g.[4, 2]) -> that pattern directly.connectorGap(float | None) - Pixel gap left at the MARKER end of the connector so it points at the dot rather than piercing it.None(default) -> the theme’smark_pointedge radius plus two connector stroke widths of whitespace (sqrt(markSize/2/pi) + markStrokeWidth + 2*axisWidth), which clears the default point mark (and the smallermark_circle) with a visible sliver of daylight at any theme scale;0-> no marker gap; a float -> that many pixels (set this for unusually large or heavily stroked markers, which the gap can’t measure since the base chart isn’t visible here). The TEXT end always keeps just the whitespace term (2*axisWidth- there is no marker to clear there, so a symmetric gap would open a hole between line and label). Both gaps are uniform - they never shrink, so every drawn connector sits the same distance off its dot and its label; a connector too short to keep the full gaps is dropped instead (seealwaysShowConnectors).alwaysShowConnectors(bool) - By default (False) a connector is omitted when the full end gaps would leave less than four connector stroke widths of visible line (length <connectorGap + 6*axisWidth, i.e. < 1 px of line at the default theme) - the stub is just noise and the adjacent label is unambiguous. This threshold is font-independent (tied to the marker gap), so changing the label font never drops real leaders.Truedraws every one (sub-threshold stubs shrink their gaps to fit).
add_shade
Section titled “add_shade”def add_shade( categories: list[str] | None = None, xCol: str | None = None, *, positions: list[tuple] | None = None, axis: str = 'x', palette: list[str] | None = None, nShades: int = 2, repeat: int = 1, opacity: float = 1.0, stroke: bool = False, strokeWidth: float | None = None, strokeDash: list[float] | bool | None = None, flush: bool | None = None, data: pl.DataFrame | Any | None = None,) -> alt.LayerChart: ...Build a background shading layer as filled mark_rect bands.
Two modes, selected by which parameters are provided:
Band mode (categories provided, positions omitted): shades every
band on the x-axis, cycling colors through palette with repeat
consecutive ticks per color. Consecutive same-color categories are merged
into a single wider rect to eliminate sub-pixel antialiasing seams in PNG
output. Always operates on axis='x'.
Positions mode (positions provided): shades explicit coordinate
ranges given as (start, end) tuples, one rect per tuple. Colors cycle
across positions (palette[i % len(palette)]).
- String tuples — category names on a nominal axis. Requires
categoriesfor index lookup. Uses pixel coordinates viaalt.valueso it does not interfere with the main chart’s scale. Supportsaxis='x','y', and'both'. - Numeric tuples — data-space coordinates on a quantitative axis.
Uses
x:Q/x2:Qory:Q/y2:Qencoding, which auto-shares the scale with the main chart’s matching channel. Supportsaxis='x','y', and'both'.
With axis='both' each position is a nested pair
((x_start, x_end), (y_start, y_end)). The two halves are resolved
independently so mixed types work (e.g. a nominal x-range combined with
a quantitative y-range).
In both modes, compose behind the main chart with +::
# band modeshade = ds.add_shade(CATEGORIES, "group")chart = shade + main_chart
# positions mode — shade two category spans on xshade = ds.add_shade( positions=[("Control", "Group B"), ("Group D", "Group E")], categories=CATEGORIES,)
# positions mode — reference band on y (quantitative)shade = ds.add_shade( positions=[(5.0, 10.0)], axis='y', palette=["#E8F4F8"])
# positions mode — intersection rect, nominal x + quantitative yshade = ds.add_shade( positions=[(("Control", "Group B"), (8.0, 12.0))], axis='both', categories=CATEGORIES,)Parameters
categories(list[str] | None) - Ordered list of axis categories. Required for band mode. Also required in positions mode when any tuple values are strings.xCol(str | None) - Column name for the x-axis grouping variable (band mode only; not used internally).positions(list[tuple] | None) - List of(start, end)tuples (single-axis) or((x_start, x_end), (y_start, y_end))tuples (axis='both') defining explicit shade regions. Activates positions mode;repeatandflushare used only when tuple values are strings.axis(str) -'x'(default),'y', or'both'. Controls which axis the shading runs along.'both'draws intersection rects spanning an explicit x-range and y-range simultaneously. Ignored in band mode (always'x').palette(list[str] | None) - List of hex color strings to cycle through in light mode. Defaults to"greys"whenNone. In dark mode this parameter is always ignored — the darkestnShadesstops of"greys"are used regardless. Resolved at call time; pass a callable tods.save()for correct darkmode rendering.nShades(int) - Number of colors to use. In light mode, slices the firstnShadesstops frompalette(or"greys"). In dark mode, slices the lastnShadesstops of"greys". Defaults to2.repeat(int) - Number of consecutive ticks sharing the same color before advancing (band mode only). Defaults to1.opacity(float) - Fill opacity of the shade rects. Defaults to1.0.stroke(bool) - Enable a border on the shade rects.False(default) → no stroke.True→ axis-style stroke: color from theme darkmode state (black / white), width fromaxisWidth.strokeWidth(float | None) - Explicit border width in pixels. OverridesaxisWidthwhenstroke=True. Has no effect whenstroke=False.strokeDash(list[float] | bool | None) - Dash pattern for the rect border.None(default) → solid.True→ inheritdashedWidthfrom the active theme. A list (e.g.[4, 2]) → use that pattern directly.flush(bool | None) - Extend the outermost rects to the axis domain edge (band mode and string positions only).Noneinherits from the theme’sclosedsetting.data(pl.DataFrame | Any | None) - Facet-safe (datum) mode, positions mode only.None(default) builds each rect from its own internal dataset — the normal behavior, but incompatible with faceting. Pass the same DataFrame you gave the base chart to share its data and position numeric ranges byalt.datum(string/pixel ranges already usealt.value), so(base + add_shade(positions=..., data=df))can be faceted and the shading repeats in every panel. Accepts polars or pandas. Band mode (positionsomitted) does not supportdata=and raises.