Skip to content

API Reference

Every rendering backend exposes the same plot, so switching canvases is a one-line import change. The backend-agnostic statistics and the column summaries live at the top level with no plotting dependency.

One API, many canvases

import pavement.matplotlib as pavement   # or .bokeh / .plotly / .holoviews
pavement.plot([1, 2, 3, 4, 5])

pavement.svg is the one exception: it draws single-row sparklines (spark), not multi-row plots.

Top-level package (pavement)

The package root re-exports the pure-Python statistics and the headline summary entry point — no backend required.

core

Backend-agnostic pavement statistics.

The pure-math core of the package: quantile computation, the quantile values that define 1D and 2D pavement plots, and the column summaries (tally_stats, proportion_stats). Nothing here imports a plotting library, so it is the shared foundation every backend (pavement.matplotlib, pavement.holoviews, pavement.plotly, pavement.bokeh) builds on. The top-level pavement package re-exports everything in __all__.

quantiles

quantiles(
    data: Iterable[float],
    levels: Sequence[float],
    weights: Sequence[float] | None = None,
    presorted: bool = False,
) -> list[float]

Compute Type 2 quantiles, optionally weighted.

Type 2 is the discontinuous quantile definition that averages two adjacent values when a level lands exactly on an order statistic. See https://robjhyndman.com/papers/sample_quantiles.pdf.

Parameters:

Name Type Description Default
data iterable of float

The values to take quantiles of. Sorted internally unless presorted is True. Missing values (NaN, None, pandas NA/NaT, and the like; see _is_missing) are dropped before any computation, along with their weights.

required
levels sequence of float

Quantile levels in [0, 1], strictly increasing.

required
weights sequence of float

Positive weights parallel to data. If None, each value contributes equally.

None
presorted bool

If True, data (and weights) are assumed already sorted by data in ascending order; the internal sort is skipped. A monotonicity check still runs and raises if the claim is false.

False

Returns:

Type Description
list of float

One value per entry in levels, in the same order.

Raises:

Type Description
ValueError

If levels is not strictly increasing in [0, 1]; if weights is given and its length doesn't match data; if data is empty (or holds only missing values); if data is not sorted when presorted is True; or if any weight is not positive.

Source code in src/pavement/core.py
def quantiles(
    data: Iterable[float],
    levels: Sequence[float],
    weights: Sequence[float] | None = None,
    presorted: bool = False,
) -> list[float]:
    """
    Compute Type 2 quantiles, optionally weighted.

    Type 2 is the discontinuous quantile definition that averages two
    adjacent values when a level lands exactly on an order statistic.
    See https://robjhyndman.com/papers/sample_quantiles.pdf.

    Parameters
    ----------
    data : iterable of float
        The values to take quantiles of. Sorted internally unless
        *presorted* is True. Missing values (``NaN``, ``None``, pandas
        ``NA``/``NaT``, and the like; see `_is_missing`) are dropped before
        any computation, along with their weights.
    levels : sequence of float
        Quantile levels in [0, 1], strictly increasing.
    weights : sequence of float, optional
        Positive weights parallel to *data*. If None, each value
        contributes equally.
    presorted : bool, default: False
        If True, *data* (and *weights*) are assumed already sorted by
        *data* in ascending order; the internal sort is skipped. A
        monotonicity check still runs and raises if the claim is false.

    Returns
    -------
    list of float
        One value per entry in *levels*, in the same order.

    Raises
    ------
    ValueError
        If *levels* is not strictly increasing in [0, 1]; if *weights*
        is given and its length doesn't match *data*; if *data* is empty
        (or holds only missing values); if *data* is not sorted when
        *presorted* is True; or if any weight is not positive.
    """
    if not (all(0 <= a < b <= 1 for a, b in zip(levels, levels[1:]))
            and all(0 <= level <= 1 for level in levels)):
        raise ValueError("levels must be strictly increasing in [0, 1]")
    data = list(data)
    if weights is not None and len(weights) != len(data):
        raise ValueError(
            f"weights has length {len(weights)}, expected {len(data)}")
    # Drop missing values (NaN, None, pandas NA/NaT, ...) before doing any
    # math: a NaN sorts unpredictably and would otherwise either corrupt the
    # quantiles silently or trip the monotonicity check with a misleading
    # "data must be sorted". Each dropped value takes its weight with it.
    if weights is None:
        data = [value for value in data if not _is_missing(value)]
    else:
        kept = [(value, weight) for value, weight in zip(data, weights)
                if not _is_missing(value)]
        data = [value for value, _ in kept]
        weights = [weight for _, weight in kept]
    if not data:
        raise ValueError("data must be non-empty")
    if not presorted:
        if weights is None:
            data = sorted(data)
        else:
            data, weights = zip(*sorted(zip(data, weights)))
    total = len(data) if weights is None else sum(weights)
    targets = [level * total for level in levels]
    level_index = 0
    value = float('-inf')
    cumulative = 0
    results = []
    for index in range(len(data)):
        if data[index] < value:
            raise ValueError("data must be sorted")
        value = data[index]
        weight = 1 if weights is None else weights[index]
        if weight <= 0:
            raise ValueError("weights must be positive")
        cumulative += weight
        while level_index < len(levels) and cumulative > targets[level_index]:
            results.append(value)
            level_index += 1
        if level_index < len(levels) and cumulative == targets[level_index]:
            next_value = data[index + 1] if index + 1 < len(data) else value
            results.append((value + next_value) / 2)
            level_index += 1
    return results

pavement_stats

pavement_stats(
    data: Iterable[float],
    bins: int | None = 4,
    weights: Sequence[float] | None = None,
    presorted: bool = False,
) -> list[float]

Compute the quantile values that define a single pavement plot.

Wraps quantiles to turn a bin count into the corresponding evenly-spaced quantile levels (0, 1/bins, ..., 1).

Parameters:

Name Type Description Default
data iterable of float

The values to summarize. Missing values (NaN, None, pandas NA/NaT, and the like; see _is_missing) are dropped before binning.

required
bins int or None

Number of equal-mass bins. Yields bins + 1 quantile values: the two endpoints plus the bins - 1 internal cut points. If None, no binning is done and every data point is returned (sorted), so the pavement shows all the data — useful for small datasets, like a rug plot.

4
weights sequence of float

Positive weights parallel to data. If None, each value contributes equally. Ignored when bins is None, since every point is shown regardless of weight.

None
presorted bool

Passed through to quantiles. If True, data (and weights) are assumed already sorted by data in ascending order.

False

Returns:

Type Description
list of float

bins + 1 quantile values in ascending order, or every data point (sorted) when bins is None.

Raises:

Type Description
ValueError

If bins is less than 1; if data is empty (or holds only missing values); if data is not sorted when presorted is True; or for any reason raised by quantiles.

See Also

quantiles : The underlying quantile computation.

Source code in src/pavement/core.py
def pavement_stats(
    data: Iterable[float],
    bins: int | None = 4,
    weights: Sequence[float] | None = None,
    presorted: bool = False,
) -> list[float]:
    """
    Compute the quantile values that define a single pavement plot.

    Wraps `quantiles` to turn a bin count into the corresponding
    evenly-spaced quantile levels (``0, 1/bins, ..., 1``).

    Parameters
    ----------
    data : iterable of float
        The values to summarize. Missing values (``NaN``, ``None``, pandas
        ``NA``/``NaT``, and the like; see `_is_missing`) are dropped before
        binning.
    bins : int or None, default: 4
        Number of equal-mass bins. Yields ``bins + 1`` quantile values:
        the two endpoints plus the ``bins - 1`` internal cut points. If
        None, no binning is done and every data point is returned
        (sorted), so the pavement shows all the data — useful for small
        datasets, like a rug plot.
    weights : sequence of float, optional
        Positive weights parallel to *data*. If None, each value
        contributes equally. Ignored when *bins* is None, since every
        point is shown regardless of weight.
    presorted : bool, default: False
        Passed through to `quantiles`. If True, *data* (and *weights*)
        are assumed already sorted by *data* in ascending order.

    Returns
    -------
    list of float
        ``bins + 1`` quantile values in ascending order, or every data
        point (sorted) when *bins* is None.

    Raises
    ------
    ValueError
        If *bins* is less than 1; if *data* is empty (or holds only
        missing values); if *data* is not sorted when *presorted* is
        True; or for any reason raised by `quantiles`.

    See Also
    --------
    quantiles : The underlying quantile computation.
    """
    if bins is None:
        # Drop missing values, as `quantiles` does for the binned path.
        data = [value for value in data if not _is_missing(value)]
        if not data:
            raise ValueError("data must be non-empty")
        if not presorted:
            return sorted(data)
        if any(later < earlier for earlier, later in zip(data, data[1:])):
            raise ValueError("data must be sorted")
        return data
    if bins < 1:
        raise ValueError(f"bins must be a positive integer, got {bins}")
    levels = [x/bins for x in range(bins + 1)]
    return quantiles(data, levels, weights, presorted=presorted)

pavement_stats2d

pavement_stats2d(
    x: Iterable[float],
    y: Iterable[float],
    weights: Sequence[float] | None = None,
    bins: int = 4,
    x_bins: int | None = None,
    y_bins: int | None = None,
    first_split: Literal["x", "y"] = "x",
) -> dict[str, Any]

Compute the box edges for a 2D pavement plot.

Sort the data along the first_split axis and partition it into primary_bins chunks of equal weight. Within each chunk, sort along the other axis and compute secondary_bins equal-weight quantiles. Every cell of the resulting grid holds the same fraction of the data, 1 / (x_bins * y_bins).

Parameters:

Name Type Description Default
x iterable of float

Paired coordinates. Must have the same length.

required
y iterable of float

Paired coordinates. Must have the same length.

required
weights sequence of float

Positive weights, one per (x, y) pair. Used for both the partition step and the inner quantile step.

None
bins int

Default number of bins along each axis.

4
x_bins int

Override bins for the respective axis.

None
y_bins int

Override bins for the respective axis.

None
first_split ('x', 'y')

Which axis to partition first. 'x' produces columns split further into y-bands; 'y' produces rows split further into x-bands. The two orderings generally give different grids.

'x'

Returns:

Type Description
dict

{'first_split': 'x' | 'y', 'primary_edges': list[float] of length primary_bins+1, 'secondary_edges_per_chunk': list of primary_bins lists, each of length secondary_bins+1}

When first_split is 'x', primary edges are x-edges and secondary edges (per chunk) are y-edges; when 'y', vice versa.

Raises:

Type Description
ValueError

If x and y have different lengths or are empty; if weights has a length that doesn't match; if x_bins or y_bins is less than 1; if there are fewer data points than the primary-axis bin count; or if a chunk ends up empty (which can happen with heavily skewed weights).

See Also

quantiles : The underlying 1D quantile computation.

Source code in src/pavement/core.py
def pavement_stats2d(
    x: Iterable[float],
    y: Iterable[float],
    weights: Sequence[float] | None = None,
    bins: int = 4,
    x_bins: int | None = None,
    y_bins: int | None = None,
    first_split: Literal['x', 'y'] = 'x',
) -> dict[str, Any]:
    """
    Compute the box edges for a 2D pavement plot.

    Sort the data along the *first_split* axis and partition it into
    *primary_bins* chunks of equal weight. Within each chunk, sort
    along the other axis and compute *secondary_bins* equal-weight
    quantiles. Every cell of the resulting grid holds the same
    fraction of the data, ``1 / (x_bins * y_bins)``.

    Parameters
    ----------
    x, y : iterable of float
        Paired coordinates. Must have the same length.
    weights : sequence of float, optional
        Positive weights, one per (x, y) pair. Used for both the
        partition step and the inner quantile step.
    bins : int, default: 4
        Default number of bins along each axis.
    x_bins, y_bins : int, optional
        Override *bins* for the respective axis.
    first_split : {'x', 'y'}, default: 'x'
        Which axis to partition first. ``'x'`` produces columns split
        further into y-bands; ``'y'`` produces rows split further into
        x-bands. The two orderings generally give different grids.

    Returns
    -------
    dict
        ``{'first_split': 'x' | 'y',
           'primary_edges': list[float] of length primary_bins+1,
           'secondary_edges_per_chunk':
               list of primary_bins lists, each of length
               secondary_bins+1}``

        When *first_split* is ``'x'``, primary edges are x-edges and
        secondary edges (per chunk) are y-edges; when ``'y'``, vice
        versa.

    Raises
    ------
    ValueError
        If *x* and *y* have different lengths or are empty; if
        *weights* has a length that doesn't match; if *x_bins* or
        *y_bins* is less than 1; if there are fewer data points than
        the primary-axis bin count; or if a chunk ends up empty
        (which can happen with heavily skewed weights).

    See Also
    --------
    quantiles : The underlying 1D quantile computation.
    """
    if first_split not in ('x', 'y'):
        raise ValueError(
            f"first_split must be 'x' or 'y', got {first_split!r}")
    if x_bins is None:
        x_bins = bins
    if y_bins is None:
        y_bins = bins
    if x_bins < 1:
        raise ValueError(f"x_bins must be a positive integer, got {x_bins}")
    if y_bins < 1:
        raise ValueError(f"y_bins must be a positive integer, got {y_bins}")

    x = list(x)
    y = list(y)
    n = len(x)
    if n != len(y):
        raise ValueError(
            f"x and y must have the same length, got {n} and {len(y)}")
    if n == 0:
        raise ValueError("x and y must be non-empty")
    if weights is not None and len(weights) != n:
        raise ValueError(
            f"weights has length {len(weights)}, expected {n}")

    if first_split == 'x':
        primary, secondary = x, y
        primary_bins, secondary_bins = x_bins, y_bins
    else:
        primary, secondary = y, x
        primary_bins, secondary_bins = y_bins, x_bins

    if n < primary_bins:
        axis = first_split
        raise ValueError(
            f"need at least {axis}_bins ({primary_bins}) data points "
            f"to split along {axis!r}, got {n}")

    indices = sorted(range(n), key=lambda i: primary[i])
    p_sorted = [primary[i] for i in indices]
    s_sorted = [secondary[i] for i in indices]
    w_sorted = [weights[i] for i in indices] if weights is not None else None

    # The drawn column edges and the chunk membership are computed by two
    # independent passes: primary_edges from Type-2 quantiles (which average
    # at an exact order statistic), and the chunks below from a greedy
    # cumulative-weight partition. For a point sitting right on a boundary
    # the two can disagree by one point, so a column's drawn extent and the
    # exact set of points feeding its secondary quantiles aren't guaranteed
    # identical. The difference is at most one boundary point and invisible
    # on typical data; equal weights and distinct values make them agree.
    p_levels = [k/primary_bins for k in range(primary_bins + 1)]
    primary_edges = quantiles(p_sorted, p_levels, w_sorted, presorted=True)

    weights_for_partition = w_sorted if w_sorted is not None else [1] * n
    total = sum(weights_for_partition)
    targets = [k * total / primary_bins for k in range(1, primary_bins + 1)]

    chunks: list[list[int]] = [[] for _ in range(primary_bins)]
    current = 0
    cumulative: float = 0.0
    for i, w in enumerate(weights_for_partition):
        chunks[current].append(i)
        cumulative += w
        while current < primary_bins - 1 and cumulative >= targets[current]:
            current += 1

    if any(len(c) == 0 for c in chunks):
        raise ValueError(
            "some chunks are empty (typically caused by heavily skewed "
            "weights); reduce bins or rebalance weights")

    s_levels = [k/secondary_bins for k in range(secondary_bins + 1)]
    secondary_edges_per_chunk = []
    for chunk_idx in chunks:
        s_chunk = [s_sorted[i] for i in chunk_idx]
        w_chunk = [w_sorted[i] for i in chunk_idx] if w_sorted is not None else None
        secondary_edges_per_chunk.append(quantiles(s_chunk, s_levels, w_chunk))

    return {
        'first_split': first_split,
        'primary_edges': primary_edges,
        'secondary_edges_per_chunk': secondary_edges_per_chunk,
    }

tally_stats

tally_stats(data: Iterable[Any]) -> dict[str, int]

Count how a column breaks down into distinct, repeated, and missing.

A backend-agnostic summary of a column (a list of values, typically one column of a table). Unlike pavement_stats, which summarizes the distribution of numeric values, this looks at the column's make-up and works on values of any type.

Each value is sorted into exactly one of three buckets, so the three counts always sum to the total:

  • missingNone, NaN, pandas NA/NaT, and the like (see _is_missing);
  • distinct — the count of distinct present values (each distinct value contributes 1, on its first occurrence); and
  • repeated — the remaining present values, each a repeat of a value already counted as distinct.

A fourth count, once, is also returned: how many of the distinct values appear exactly once in the column (often called "unique"). It is a sub-count of distinct (once <= distinct), overlapping it rather than adding to the three-bucket total.

Parameters:

Name Type Description Default
data iterable

The column's values, of any type. Need not be numeric or hashable; unhashable values fall back to equality-based grouping.

required

Returns:

Type Description
dict of str to int

{'distinct': d, 'once': u, 'repeated': r, 'missing': m, 'total': n} with d + r + m == n and u <= d. An empty column gives all zeros.

See Also

pavement.svg.tally : Render this summary as an inline SVG strip.

Source code in src/pavement/core.py
def tally_stats(data: Iterable[Any]) -> dict[str, int]:
    """
    Count how a column breaks down into distinct, repeated, and missing.

    A backend-agnostic summary of a column (a list of values, typically one
    column of a table). Unlike `pavement_stats`, which summarizes the
    *distribution* of numeric values, this looks at the column's make-up and
    works on values of any type.

    Each value is sorted into exactly one of three buckets, so the three
    counts always sum to the total:

    - **missing** — ``None``, ``NaN``, pandas ``NA``/``NaT``, and the like
      (see `_is_missing`);
    - **distinct** — the count of *distinct* present values (each distinct
      value contributes 1, on its first occurrence); and
    - **repeated** — the remaining present values, each a repeat of a value
      already counted as distinct.

    A fourth count, **once**, is also returned: how many of the distinct
    values appear *exactly once* in the column (often called "unique"). It
    is a sub-count of *distinct* (``once <= distinct``), overlapping it
    rather than adding to the three-bucket total.

    Parameters
    ----------
    data : iterable
        The column's values, of any type. Need not be numeric or hashable;
        unhashable values fall back to equality-based grouping.

    Returns
    -------
    dict of str to int
        ``{'distinct': d, 'once': u, 'repeated': r, 'missing': m,
        'total': n}`` with ``d + r + m == n`` and ``u <= d``. An empty
        column gives all zeros.

    See Also
    --------
    pavement.svg.tally : Render this summary as an inline SVG strip.
    """
    present: list[Any] = []
    missing = 0
    for value in data:
        if _is_missing(value):
            missing += 1
        else:
            present.append(value)
    # The frequency of each distinct present value, so we can count both
    # the distinct values and the subset appearing exactly once.
    try:
        frequencies = list(Counter(present).values())
    except TypeError:  # unhashable values: group by equality instead
        groups: list[list[Any]] = []  # [representative, count] pairs
        for value in present:
            for group in groups:
                if group[0] == value:
                    group[1] += 1
                    break
            else:
                groups.append([value, 1])
        frequencies = [count for _, count in groups]
    distinct = len(frequencies)
    once = sum(1 for count in frequencies if count == 1)
    repeated = len(present) - distinct
    return {
        'distinct': distinct,
        'once': once,
        'repeated': repeated,
        'missing': missing,
        'total': distinct + repeated + missing,
    }

proportion_stats

proportion_stats(data: Iterable[Any]) -> dict[str, Any]

Value counts of a column, descending by frequency.

A backend-agnostic summary in the spirit of pandas value_counts(): how a column's present values divide up by how often each occurs. Missing values (see _is_missing) are dropped, like value_counts(dropna=True); their number is reported separately.

Where tally_stats summarizes a column's make-up (distinct / repeated / missing), this exposes the shape of its value distribution — the input a proportion plot draws.

Parameters:

Name Type Description Default
data iterable

The column's values, of any type. Need not be hashable; unhashable values fall back to equality-based grouping.

required

Returns:

Type Description
dict

{'counts': [(value, count), ...], 'total': t, 'missing': m}. counts is sorted by descending count, ties broken by first appearance; total is the number of present (non-missing) values, equal to the sum of the counts; missing is how many were dropped.

See Also

pavement.svg.proportion : Render these counts as an inline SVG strip. tally_stats : The companion distinct/repeated/missing summary.

Source code in src/pavement/core.py
def proportion_stats(data: Iterable[Any]) -> dict[str, Any]:
    """
    Value counts of a column, descending by frequency.

    A backend-agnostic summary in the spirit of pandas ``value_counts()``:
    how a column's present values divide up by how often each occurs.
    Missing values (see `_is_missing`) are dropped, like
    ``value_counts(dropna=True)``; their number is reported separately.

    Where `tally_stats` summarizes a column's make-up (distinct / repeated /
    missing), this exposes the *shape* of its value distribution — the input
    a proportion plot draws.

    Parameters
    ----------
    data : iterable
        The column's values, of any type. Need not be hashable; unhashable
        values fall back to equality-based grouping.

    Returns
    -------
    dict
        ``{'counts': [(value, count), ...], 'total': t, 'missing': m}``.
        *counts* is sorted by descending count, ties broken by first
        appearance; *total* is the number of present (non-missing) values,
        equal to the sum of the counts; *missing* is how many were dropped.

    See Also
    --------
    pavement.svg.proportion : Render these counts as an inline SVG strip.
    tally_stats : The companion distinct/repeated/missing summary.
    """
    present: list[Any] = []
    missing = 0
    for value in data:
        if _is_missing(value):
            missing += 1
        else:
            present.append(value)
    try:
        counts: dict[Any, int] = {}
        first: dict[Any, int] = {}
        for index, value in enumerate(present):
            if value in counts:
                counts[value] += 1
            else:
                counts[value] = 1
                first[value] = index
        items = sorted(counts.items(), key=lambda kv: (-kv[1], first[kv[0]]))
    except TypeError:  # unhashable values: group by equality instead
        groups: list[list[Any]] = []  # [value, count, first_index]
        for index, value in enumerate(present):
            for group in groups:
                if group[0] == value:
                    group[1] += 1
                    break
            else:
                groups.append([value, 1, index])
        groups.sort(key=lambda g: (-g[1], g[2]))
        items = [(value, count) for value, count, _ in groups]
    return {'counts': items, 'total': len(present), 'missing': missing}

Static plots (pavement.matplotlib)

matplotlib

Static pavement plots with matplotlib.

The matplotlib backend draws pavements as matplotlib artists on an Axes. Beyond the shared plot API (single, wide, or tidy data; a rug with bins=None; orientation), this backend adds three things the interactive backends don't have: 2D pavements (plot2d), a single-strip marginal (margin) with rich inside/outside placement, and a borderless, word-sized inline image (spark).

The backend-agnostic statistics live in pavement.core; the shared row geometry in pavement._geometry.

draw_pavement

draw_pavement(
    values: Sequence[float],
    position: float = 1,
    width: float = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = True,
    orientation: Literal[
        "vertical", "horizontal"
    ] = "vertical",
    line_props: Mapping[str, Any] | None = None,
    box_props: Mapping[str, Any] | None = None,
    data: Sequence[float] | None = None,
    ax: Axes | None = None,
) -> dict[str, Any]

Draw a single pavement row from precomputed quantile values.

Renders one tick per distinct value perpendicular to the value axis and a box outline spanning values[0] to values[-1]. A value that repeats (a sign the data is concentrated there) reaches past the box as a tassel, so every line is drawn exactly once.

Parameters:

Name Type Description Default
values sequence of float

Quantile values in ascending order, as returned by pavement_stats.

required
position float

Center of the row along the axis perpendicular to the value axis. For orientation='vertical' this is an x-coordinate; for orientation='horizontal' it is a y-coordinate. The default matches matplotlib's boxplot, which places a single box at position 1.

1
width float

Total thickness of the box outline (perpendicular to the value axis).

0.6
tassel_extent float

How far the tassel marks extend beyond the box, perpendicular to the value axis. Unrelated to matplotlib's boxplot(whis=), which controls outlier cutoffs on the value axis.

0.05
show_tassels bool

If False, suppress the tassel marks even at repeated values.

False
show_box bool or None

How to draw the long box edges (the borders parallel to the value axis, perpendicular to the value ticks). True (the default here) draws the complete box — both edges unbroken across the value range. False drops them, leaving only the ticks (a plain rug). None is the auto mode: each bin contributes its edges only where it holds a data point strictly inside it, so the box closes where values are spread and gaps open where the mass clumps onto a value line — which needs data to be given (without it the auto box is empty). The higher-level plot passes None by default, so a binned pavement gaps and a rug shows no box.

True
orientation ('vertical', 'horizontal')

Direction of the value axis. 'vertical' puts values on the y-axis (matplotlib's boxplot default); 'horizontal' puts them on the x-axis.

'vertical'
line_props dict

Line2D properties (color, linewidth, linestyle, alpha, ...) passed through to the underlying Axes.vlines / Axes.hlines calls. Applied uniformly to the quantile ticks (tassels included) and box edges. Defaults to {'color': 'black', 'linewidth': 1.0}; partial overrides merge on top of that default (e.g. passing {'linewidth': 2} keeps lines black).

None
box_props dict

If given, a filled ~matplotlib.patches.Rectangle is drawn behind the box as a background; the dict supplies its properties (facecolor, alpha, hatch, ...). It defaults to no edge, so it doesn't double the box outline. If None (the default), no background is drawn.

None
data sequence of float

The raw values values was computed from. Only the auto box (show_box=None) uses it — to count how many points fall strictly inside each bin and so decide which bin edges to draw. Without it the auto box is empty; show_box True/False ignore it.

None
ax matplotlib Axes

Axes to draw on. Defaults to plt.gca().

None

Returns:

Type Description
dict

Maps component name to the artist added to the axes:

  • "fill": the background ~matplotlib.patches.Rectangle, or None if box_props was not given.
  • "ticks": one tick per distinct quantile value, extended into a tassel where the value repeats.
  • "box": the long box edges (one ~matplotlib.collections.LineCollection of every drawn edge segment), or None when no edge is drawn — show_box False, or the auto box finds no bin with interior data.

Raises:

Type Description
ValueError

If values is empty, or if orientation is not 'vertical' or 'horizontal'.

See Also

pavement_stats : Compute the values to pass in. plot : One-call convenience that combines stats and drawing.

Source code in src/pavement/matplotlib.py
def draw_pavement(
    values: Sequence[float],
    position: float = 1,
    width: float = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = True,
    orientation: Literal['vertical', 'horizontal'] = 'vertical',
    line_props: Mapping[str, Any] | None = None,
    box_props: Mapping[str, Any] | None = None,
    data: Sequence[float] | None = None,
    ax: Axes | None = None,
) -> dict[str, Any]:
    """
    Draw a single pavement row from precomputed quantile values.

    Renders one tick per distinct value perpendicular to the value
    axis and a box outline spanning ``values[0]`` to ``values[-1]``. A
    value that repeats (a sign the data is concentrated there) reaches
    past the box as a tassel, so every line is drawn exactly once.

    Parameters
    ----------
    values : sequence of float
        Quantile values in ascending order, as returned by
        `pavement_stats`.
    position : float, default: 1
        Center of the row along the axis perpendicular to the value
        axis. For ``orientation='vertical'`` this is an x-coordinate;
        for ``orientation='horizontal'`` it is a y-coordinate. The
        default matches matplotlib's ``boxplot``, which places a
        single box at position 1.
    width : float, default: 0.6
        Total thickness of the box outline (perpendicular to the
        value axis).
    tassel_extent : float, default: 0.05
        How far the tassel marks extend beyond the box, perpendicular
        to the value axis. Unrelated to matplotlib's ``boxplot(whis=)``,
        which controls outlier cutoffs on the value axis.
    show_tassels : bool, default: False
        If False, suppress the tassel marks even at repeated values.
    show_box : bool or None, default: True
        How to draw the long box edges (the borders parallel to the value
        axis, perpendicular to the value ticks). ``True`` (the default here)
        draws the complete box — both edges unbroken across the value range.
        ``False`` drops them, leaving only the ticks (a plain rug). ``None``
        is the auto mode: each bin contributes its edges only where it holds
        a data point strictly inside it, so the box closes where values are
        spread and gaps open where the mass clumps onto a value line — which
        needs *data* to be given (without it the auto box is empty). The
        higher-level `plot` passes ``None`` by default, so a binned pavement
        gaps and a rug shows no box.
    orientation : {'vertical', 'horizontal'}, default: 'vertical'
        Direction of the value axis. 'vertical' puts values on the
        y-axis (matplotlib's boxplot default); 'horizontal' puts them
        on the x-axis.
    line_props : dict, optional
        Line2D properties (color, linewidth, linestyle, alpha, ...)
        passed through to the underlying ``Axes.vlines`` /
        ``Axes.hlines`` calls. Applied uniformly to the quantile ticks
        (tassels included) and box edges. Defaults to
        ``{'color': 'black', 'linewidth': 1.0}``; partial overrides
        merge on top of that default (e.g. passing ``{'linewidth': 2}``
        keeps lines black).
    box_props : dict, optional
        If given, a filled `~matplotlib.patches.Rectangle` is drawn
        behind the box as a background; the dict supplies its
        properties (facecolor, alpha, hatch, ...). It defaults to no
        edge, so it doesn't double the box outline. If None (the
        default), no background is drawn.
    data : sequence of float, optional
        The raw values *values* was computed from. Only the auto box
        (``show_box=None``) uses it — to count how many points fall strictly
        inside each bin and so decide which bin edges to draw. Without it the
        auto box is empty; ``show_box`` True/False ignore it.
    ax : matplotlib Axes, optional
        Axes to draw on. Defaults to ``plt.gca()``.

    Returns
    -------
    dict
        Maps component name to the artist added to the axes:

        - ``"fill"``: the background `~matplotlib.patches.Rectangle`,
          or ``None`` if *box_props* was not given.
        - ``"ticks"``: one tick per distinct quantile value, extended
          into a tassel where the value repeats.
        - ``"box"``: the long box edges (one `~matplotlib.collections.LineCollection`
          of every drawn edge segment), or ``None`` when no edge is drawn —
          ``show_box`` False, or the auto box finds no bin with interior data.

    Raises
    ------
    ValueError
        If *values* is empty, or if *orientation* is not 'vertical' or
        'horizontal'.

    See Also
    --------
    pavement_stats : Compute the values to pass in.
    plot : One-call convenience that combines stats and drawing.
    """
    if len(values) == 0:
        raise ValueError("values must be non-empty")
    if orientation not in ('vertical', 'horizontal'):
        raise ValueError(
            f"orientation must be 'vertical' or 'horizontal', got {orientation!r}")
    if ax is None:
        ax = plt.gca()
    spec = row_spec(values, position, width, orientation,
                    tassel_extent, show_tassels, data=data)
    # 'perp' draws the ticks (across the row); 'along' the box edges (down
    # the value axis). They swap roles with orientation.
    perp, along = (ax.hlines, ax.vlines) if orientation == 'vertical' \
        else (ax.vlines, ax.hlines)
    props = {'color': 'black', 'linewidth': 1.0, **(line_props or {})}
    pos_lo, pos_hi = position - spec.half, position + spec.half
    artists: dict[str, Any] = {'fill': None}
    if box_props is not None:
        # Drawn first so it sits behind the lines. The value axis runs
        # along x for 'horizontal', along y for 'vertical'.
        if orientation == 'horizontal':
            xy = (spec.value_low, pos_lo)
            w, h = spec.value_high - spec.value_low, pos_hi - pos_lo
        else:
            xy = (pos_lo, spec.value_low)
            w, h = pos_hi - pos_lo, spec.value_high - spec.value_low
        artists['fill'] = ax.add_patch(
            Rectangle(xy, w, h, **{'edgecolor': 'none', **box_props}))
    # One tick per distinct value, reaching `reach` to either side of the
    # row center — past the box (a tassel) where the value repeats, so
    # every line is drawn exactly once.
    artists['ticks'] = perp(
        [t.value for t in spec.ticks],
        [position - t.reach for t in spec.ticks],
        [position + t.reach for t in spec.ticks],
        **props)
    # The long box edges run along the value axis (perpendicular to the
    # ticks). `box_edge_spans` decides where: the auto box (show_box None,
    # with data) closes each populated bin and gaps open where the mass
    # clumps onto a value line; True forces one unbroken span; False (and a
    # rug, whose bins have no interior) draws none — leaving a plain rug. Each
    # span draws both sides, gathered into one LineCollection.
    spans = box_edge_spans(spec, show_box)
    if spans:
        sides, lows, highs = [], [], []
        for low, high in spans:
            for side in (pos_lo, pos_hi):
                sides.append(side)
                lows.append(low)
                highs.append(high)
        artists['box'] = along(sides, lows, highs, **props)
    else:
        artists['box'] = None
    return artists

plot

plot(
    data: Sequence[float] | Sequence[Iterable[float]],
    weights: Sequence[float]
    | Sequence[Sequence[float]]
    | None = None,
    positions: Sequence[float] | None = None,
    categories: Sequence[Hashable] | None = None,
    labels: Sequence[Hashable] | None = None,
    bins: int | None | Sequence[int | None] = 4,
    widths: float | Sequence[float] = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = None,
    orientation: Literal[
        "vertical", "horizontal"
    ] = "vertical",
    value_label: str | None = None,
    color: str | Sequence[str] | None = None,
    fill_alpha: float = 0.3,
    line_props: Mapping[str, Any]
    | Sequence[Mapping[str, Any]]
    | None = None,
    box_props: Mapping[str, Any]
    | Sequence[Mapping[str, Any]]
    | None = None,
    ax: Axes | None = None,
) -> list[dict[str, Any]]

Draw one or more pavement rows.

The matplotlib backend's headline function — the counterpart of pavement.bokeh.plot, pavement.plotly.plot, and pavement.holoviews.plot. Accepts the same three input shapes:

  • A 1D sequence of values: a single row.
  • A sequence of 1D sequences: one row per dataset, at positions (matching matplotlib's boxplot: data[0] at the smallest position).
  • A 1D sequence plus categories: tidy/long form. The data is split by category and rendered as in the wide form.

Parameters:

Name Type Description Default
data sequence of float, or sequence of iterables of float

The values to plot. Shape determines which mode is used.

required
weights sequence

Positive weights. Must match the shape of data: flat for a single row or tidy form, nested for wide form.

None
positions sequence of float

Position of each row along the axis perpendicular to the value axis. Defaults to [1, 2, ..., N], matching matplotlib's boxplot. Length must equal the number of rows.

None
categories sequence

Category label per entry in data, parallel to data. If given, data is treated as tidy/long form and split by category.

None
labels sequence

One label per row, in row order. In tidy form, also selects which categories to include and their order. When given (or in tidy form), the rows are ticked on the position axis — the x-axis for orientation='vertical', the y-axis otherwise.

None
bins int, None, or sequence of (int or None)

Number of equal-mass bins per row. A scalar applies to every row; a sequence sets each row's bin count individually and must have length equal to the number of rows. None shows all the data for that row instead of binning it (see pavement_stats); a sequence may mix None and integer entries.

4
widths float or sequence of float

Thickness of each row's box outline. A scalar applies to every row; a sequence sets each row's width individually and must have length equal to the number of rows.

0.6
tassel_extent float

How far the tassel marks extend beyond the box, perpendicular to the value axis. Unrelated to matplotlib's boxplot(whis=), which controls outlier cutoffs on the value axis.

0.05
show_tassels bool

If False, suppress tassel marks at repeated quantile values.

False
show_box bool or None

Whether to draw each row's two long box edges (the borders parallel to the value axis). None (the default) draws them for a binned row and omits them for a rug (bins=None), so a rug reads like a plain rug plot while a binned pavement keeps its box; True or False forces the choice for every row. Resolved per row, so a mixed bins sequence gets the right default for each.

None
orientation ('vertical', 'horizontal')

Direction of the value axis. 'vertical' puts values on the y-axis (matplotlib's boxplot default); 'horizontal' puts them on the x-axis.

'vertical'
value_label str

If given, label the value axis (y for vertical, x otherwise). The shared name for this across backends; matplotlib leaves the axis unlabelled by default (None), where the interactive backends default to 'value'. There is no value_format counterpart here: this static backend draws no hover tooltips, so values are never formatted for display the way they are in the bokeh/plotly/holoviews backends.

None
color str or sequence of str

Per-row color convenience. A single color applies to every row; a sequence sets each row and must match the number of rows. It tints the lines and, unless box_props is given for that row, draws a translucent fill of the same color (see fill_alpha). Defaults to None: black lines and no fill, unless line_props / box_props say otherwise. For full control use those instead; a per-row line_props color overrides color.

None
fill_alpha float

Opacity of the fill drawn for a row that has a color but no explicit box_props. Ignored when no such fill is drawn.

0.3
line_props dict or sequence of dict

Per-row line styling. A single dict applies to every row; a sequence sets each row individually and must have length equal to the number of rows. See draw_pavement for the dict semantics.

None
box_props dict or sequence of dict

Per-row background fill. A single dict applies to every row; a sequence sets each row individually and must have length equal to the number of rows. Takes precedence over color for the fill. See draw_pavement for the dict semantics.

None
ax matplotlib Axes

Axes to draw on. Defaults to plt.gca().

None

Returns:

Type Description
list of dict

One artist dict per row, in the same order as the rows. Each dict has the shape returned by draw_pavement.

Raises:

Type Description
ValueError

If data is empty; if positions, bins, widths, color, labels, line_props, or box_props is given as a sequence with the wrong length; or for any reason raised by the underlying pavement_stats or draw_pavement calls (e.g. non-positive bins or invalid orientation).

See Also

pavement_stats : Compute quantile values for one dataset. draw_pavement : Render one row from precomputed values. pavement.bokeh.plot : The Bokeh equivalent.

Source code in src/pavement/matplotlib.py
def plot(
    data: Sequence[float] | Sequence[Iterable[float]],
    weights: Sequence[float] | Sequence[Sequence[float]] | None = None,
    positions: Sequence[float] | None = None,
    categories: Sequence[Hashable] | None = None,
    labels: Sequence[Hashable] | None = None,
    bins: int | None | Sequence[int | None] = 4,
    widths: float | Sequence[float] = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = None,
    orientation: Literal['vertical', 'horizontal'] = 'vertical',
    value_label: str | None = None,
    color: str | Sequence[str] | None = None,
    fill_alpha: float = 0.3,
    line_props: Mapping[str, Any] | Sequence[Mapping[str, Any]] | None = None,
    box_props: Mapping[str, Any] | Sequence[Mapping[str, Any]] | None = None,
    ax: Axes | None = None,
) -> list[dict[str, Any]]:
    """
    Draw one or more pavement rows.

    The matplotlib backend's headline function — the counterpart of
    `pavement.bokeh.plot`, `pavement.plotly.plot`, and
    `pavement.holoviews.plot`. Accepts the same three input shapes:

    - A 1D sequence of values: a single row.
    - A sequence of 1D sequences: one row per dataset, at *positions*
      (matching matplotlib's ``boxplot``: ``data[0]`` at the smallest
      position).
    - A 1D sequence plus *categories*: tidy/long form. The data is
      split by category and rendered as in the wide form.

    Parameters
    ----------
    data : sequence of float, or sequence of iterables of float
        The values to plot. Shape determines which mode is used.
    weights : sequence, optional
        Positive weights. Must match the shape of *data*: flat for a
        single row or tidy form, nested for wide form.
    positions : sequence of float, optional
        Position of each row along the axis perpendicular to the
        value axis. Defaults to ``[1, 2, ..., N]``, matching
        matplotlib's ``boxplot``. Length must equal the number of
        rows.
    categories : sequence, optional
        Category label per entry in *data*, parallel to *data*. If
        given, *data* is treated as tidy/long form and split by
        category.
    labels : sequence, optional
        One label per row, in row order. In tidy form, also selects
        which categories to include and their order. When given (or in
        tidy form), the rows are ticked on the position axis — the
        x-axis for ``orientation='vertical'``, the y-axis otherwise.
    bins : int, None, or sequence of (int or None), default: 4
        Number of equal-mass bins per row. A scalar applies to every
        row; a sequence sets each row's bin count individually and
        must have length equal to the number of rows. None shows all
        the data for that row instead of binning it (see
        `pavement_stats`); a sequence may mix None and integer entries.
    widths : float or sequence of float, default: 0.6
        Thickness of each row's box outline. A scalar applies to
        every row; a sequence sets each row's width individually and
        must have length equal to the number of rows.
    tassel_extent : float, default: 0.05
        How far the tassel marks extend beyond the box, perpendicular
        to the value axis. Unrelated to matplotlib's ``boxplot(whis=)``,
        which controls outlier cutoffs on the value axis.
    show_tassels : bool, default: False
        If False, suppress tassel marks at repeated quantile values.
    show_box : bool or None, default: None
        Whether to draw each row's two long box edges (the borders
        parallel to the value axis). None (the default) draws them for a
        binned row and omits them for a rug (``bins=None``), so a rug
        reads like a plain rug plot while a binned pavement keeps its box;
        True or False forces the choice for every row. Resolved per row,
        so a mixed *bins* sequence gets the right default for each.
    orientation : {'vertical', 'horizontal'}, default: 'vertical'
        Direction of the value axis. 'vertical' puts values on the
        y-axis (matplotlib's boxplot default); 'horizontal' puts them
        on the x-axis.
    value_label : str, optional
        If given, label the value axis (y for vertical, x otherwise).
        The shared name for this across backends; matplotlib leaves the
        axis unlabelled by default (``None``), where the interactive
        backends default to ``'value'``. There is no ``value_format``
        counterpart here: this static backend draws no hover tooltips, so
        values are never formatted for display the way they are in the
        ``bokeh``/``plotly``/``holoviews`` backends.
    color : str or sequence of str, optional
        Per-row color convenience. A single color applies to every row;
        a sequence sets each row and must match the number of rows. It
        tints the lines and, unless *box_props* is given for that row,
        draws a translucent fill of the same color (see *fill_alpha*).
        Defaults to None: black lines and no fill, unless *line_props* /
        *box_props* say otherwise. For full control use those instead;
        a per-row *line_props* color overrides *color*.
    fill_alpha : float, default: 0.3
        Opacity of the fill drawn for a row that has a *color* but no
        explicit *box_props*. Ignored when no such fill is drawn.
    line_props : dict or sequence of dict, optional
        Per-row line styling. A single dict applies to every row; a
        sequence sets each row individually and must have length equal
        to the number of rows. See `draw_pavement` for the dict
        semantics.
    box_props : dict or sequence of dict, optional
        Per-row background fill. A single dict applies to every row; a
        sequence sets each row individually and must have length equal
        to the number of rows. Takes precedence over *color* for the
        fill. See `draw_pavement` for the dict semantics.
    ax : matplotlib Axes, optional
        Axes to draw on. Defaults to ``plt.gca()``.

    Returns
    -------
    list of dict
        One artist dict per row, in the same order as the rows. Each
        dict has the shape returned by `draw_pavement`.

    Raises
    ------
    ValueError
        If *data* is empty; if *positions*, *bins*, *widths*, *color*,
        *labels*, *line_props*, or *box_props* is given as a sequence
        with the wrong length; or for any reason raised by the
        underlying `pavement_stats` or `draw_pavement` calls (e.g.
        non-positive *bins* or invalid *orientation*).

    See Also
    --------
    pavement_stats : Compute quantile values for one dataset.
    draw_pavement : Render one row from precomputed values.
    pavement.bokeh.plot : The Bokeh equivalent.
    """
    data, weight_rows, labels, labelled = normalize_rows(
        data, weights, categories, labels)
    n = len(data)
    if positions is None:
        positions = list(range(1, n + 1))
    elif len(positions) != n:
        raise ValueError(
            f"positions has length {len(positions)}, expected {n}")
    bins = broadcast(bins, n, "bins",
                     lambda v: v is None or isinstance(v, Integral))
    widths = broadcast(widths, n, "widths", lambda v: isinstance(v, Number))
    if color is None:
        colors: list[Any] = [None] * n
    else:
        colors = broadcast(color, n, "color", lambda v: isinstance(v, str))
    line_props = broadcast(line_props, n, "line_props",
                           lambda v: v is None or isinstance(v, Mapping))
    box_props = broadcast(box_props, n, "box_props",
                          lambda v: v is None or isinstance(v, Mapping))
    if ax is None:
        ax = plt.gca()
    artists = []
    for dataset, w, pos, b, width, col, lp, bp in zip(
            data, weight_rows, positions, bins, widths, colors,
            line_props, box_props):
        # color is a convenience: tint the lines, and (unless box_props is
        # given for this row) draw a translucent fill of the same color. A
        # per-row line_props color wins over color.
        row_line = {**({'color': col} if col is not None else {}), **(lp or {})}
        if bp is None and col is not None:
            bp = {'facecolor': col, 'alpha': fill_alpha}
        values = pavement_stats(dataset, bins=b, weights=w)
        artists.append(draw_pavement(
            values, position=pos, width=width,
            tassel_extent=tassel_extent, show_tassels=show_tassels,
            show_box=show_box,
            orientation=orientation, line_props=row_line or None,
            box_props=bp, data=dataset, ax=ax))
    if labelled:
        set_ticks = ax.set_xticks if orientation == 'vertical' else ax.set_yticks
        set_ticks(list(positions), [str(label) for label in labels])
    if value_label is not None:
        if orientation == 'vertical':
            ax.set_ylabel(value_label)
        else:
            ax.set_xlabel(value_label)
    return artists

spark

spark(
    data: Sequence[float],
    weights: Sequence[float] | None = None,
    bins: int | None = 4,
    orientation: Literal[
        "vertical", "horizontal"
    ] = "horizontal",
    width: float = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = None,
    color: str | None = None,
    fill_alpha: float = 0.3,
    line_props: Mapping[str, Any] | None = None,
    box_props: Mapping[str, Any] | None = None,
    figsize: tuple[float, float] | None = None,
    dpi: float = 200,
    pad: float = 0.0,
    transparent: bool = True,
    path: str | None = None,
) -> Figure

Render a single pavement as a borderless inline "sparkline" image.

A spark is a pavement stripped to its ink: one row drawn on its own figure, with no axes, ticks, labels, or surrounding whitespace, so the box and tassels run right to the edges of the image. The main use is to save a small PNG and drop it inline in text, sized to sit among words like one of Tufte's sparklines.

Unlike plot, this draws exactly one distribution (a 1D sequence of values) and owns its figure — it creates one rather than drawing on a shared ~matplotlib.axes.Axes. It defaults to 'horizontal' so the value axis runs left-to-right like the surrounding text.

Parameters:

Name Type Description Default
data sequence of float

The values to summarize as a single pavement row.

required
weights sequence of float

Positive weights parallel to data.

None
bins int or None

Number of equal-mass bins. None shows all the data instead of binning it (see pavement_stats).

4
orientation ('vertical', 'horizontal')

Direction of the value axis. 'horizontal' (the default here, unlike plot) runs values left-to-right, the natural fit for an inline strip; 'vertical' runs them bottom-to-top.

'vertical'
width float

Thickness of the box outline, perpendicular to the value axis. Only its ratio to tassel_extent matters — the figure is scaled to fit whatever is drawn.

0.6
tassel_extent float

How far tassel marks extend beyond the box at repeated values.

0.05
show_tassels bool

Whether to draw tassel marks at repeated quantile values.

False
show_box bool or None

Whether to draw the two long box edges. None (the default) draws them when binned and omits them for a rug (bins=None), so a rug spark reads like a plain rug; True or False forces it.

None
color str

Tints the lines and, unless box_props is given, draws a translucent fill of the same color (see fill_alpha). Defaults to black lines and no fill.

None
fill_alpha float

Opacity of the fill drawn when color is given without an explicit box_props.

0.3
line_props dict

Line2D properties for the ticks and box edges. See draw_pavement. A color here overrides color.

None
box_props dict

Background fill properties; takes precedence over color for the fill. See draw_pavement.

None
figsize (float, float)

Figure size in inches. Defaults to a word-sized strip — (1.4, 0.3) for horizontal, transposed for vertical.

None
dpi float

Dots per inch, high enough that the small image stays crisp in print. With the default figsize a horizontal spark is about 280x60 pixels.

200
pad float

Extra breathing room around the drawn geometry, as a fraction of its extent on each axis. Defaults to none: the box runs flush to the image edge (the half-stroke needed to keep the outermost lines from being clipped is always added on top of this, so a flush edge still shows its full thickness). Raise it to inset the pavement within the image.

0.0
transparent bool

Draw on a transparent background (and save with transparency), so the strip blends into whatever it's placed on.

True
path str

If given, the figure is saved here (e.g. a .png) at dpi with no extra border. The ~matplotlib.figure.Figure is returned either way.

None

Returns:

Type Description
Figure

The figure holding the spark. The caller is responsible for closing it (e.g. plt.close(fig)) when done.

See Also

plot : Draw one or more pavement rows on a shared Axes. draw_pavement : The underlying single-row renderer.

Source code in src/pavement/matplotlib.py
def spark(
    data: Sequence[float],
    weights: Sequence[float] | None = None,
    bins: int | None = 4,
    orientation: Literal['vertical', 'horizontal'] = 'horizontal',
    width: float = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = None,
    color: str | None = None,
    fill_alpha: float = 0.3,
    line_props: Mapping[str, Any] | None = None,
    box_props: Mapping[str, Any] | None = None,
    figsize: tuple[float, float] | None = None,
    dpi: float = 200,
    pad: float = 0.0,
    transparent: bool = True,
    path: str | None = None,
) -> Figure:
    """
    Render a single pavement as a borderless inline "sparkline" image.

    A spark is a pavement stripped to its ink: one row drawn on its own
    figure, with no axes, ticks, labels, or surrounding whitespace, so
    the box and tassels run right to the edges of the image. The main
    use is to save a small PNG and drop it inline in text, sized to sit
    among words like one of Tufte's sparklines.

    Unlike `plot`, this draws exactly one distribution (a 1D sequence of
    values) and owns its figure — it creates one rather than drawing on
    a shared `~matplotlib.axes.Axes`. It defaults to ``'horizontal'`` so
    the value axis runs left-to-right like the surrounding text.

    Parameters
    ----------
    data : sequence of float
        The values to summarize as a single pavement row.
    weights : sequence of float, optional
        Positive weights parallel to *data*.
    bins : int or None, default: 4
        Number of equal-mass bins. None shows all the data instead of
        binning it (see `pavement_stats`).
    orientation : {'vertical', 'horizontal'}, default: 'horizontal'
        Direction of the value axis. 'horizontal' (the default here,
        unlike `plot`) runs values left-to-right, the natural fit for an
        inline strip; 'vertical' runs them bottom-to-top.
    width : float, default: 0.6
        Thickness of the box outline, perpendicular to the value axis.
        Only its ratio to *tassel_extent* matters — the figure is
        scaled to fit whatever is drawn.
    tassel_extent : float, default: 0.05
        How far tassel marks extend beyond the box at repeated values.
    show_tassels : bool, default: False
        Whether to draw tassel marks at repeated quantile values.
    show_box : bool or None, default: None
        Whether to draw the two long box edges. None (the default) draws
        them when binned and omits them for a rug (``bins=None``), so a
        rug spark reads like a plain rug; True or False forces it.
    color : str, optional
        Tints the lines and, unless *box_props* is given, draws a
        translucent fill of the same color (see *fill_alpha*). Defaults
        to black lines and no fill.
    fill_alpha : float, default: 0.3
        Opacity of the fill drawn when *color* is given without an
        explicit *box_props*.
    line_props : dict, optional
        Line2D properties for the ticks and box edges. See
        `draw_pavement`. A color here overrides *color*.
    box_props : dict, optional
        Background fill properties; takes precedence over *color* for
        the fill. See `draw_pavement`.
    figsize : (float, float), optional
        Figure size in inches. Defaults to a word-sized strip —
        ``(1.4, 0.3)`` for horizontal, transposed for vertical.
    dpi : float, default: 200
        Dots per inch, high enough that the small image stays crisp in
        print. With the default *figsize* a horizontal spark is about
        280x60 pixels.
    pad : float, default: 0.0
        Extra breathing room around the drawn geometry, as a fraction of
        its extent on each axis. Defaults to none: the box runs flush to
        the image edge (the half-stroke needed to keep the outermost
        lines from being clipped is always added on top of this, so a
        flush edge still shows its full thickness). Raise it to inset the
        pavement within the image.
    transparent : bool, default: True
        Draw on a transparent background (and save with transparency),
        so the strip blends into whatever it's placed on.
    path : str, optional
        If given, the figure is saved here (e.g. a ``.png``) at *dpi*
        with no extra border. The `~matplotlib.figure.Figure` is
        returned either way.

    Returns
    -------
    matplotlib.figure.Figure
        The figure holding the spark. The caller is responsible for
        closing it (e.g. ``plt.close(fig)``) when done.

    See Also
    --------
    plot : Draw one or more pavement rows on a shared Axes.
    draw_pavement : The underlying single-row renderer.
    """
    if figsize is None:
        figsize = (1.4, 0.3) if orientation == 'horizontal' else (0.3, 1.4)
    values = pavement_stats(data, bins=bins, weights=weights)
    fig = plt.figure(figsize=figsize, dpi=dpi)
    # An axes filling the whole figure: the pavement is the entire image.
    ax = fig.add_axes((0, 0, 1, 1))
    # color is a convenience, mirroring plot(): tint the lines and, unless
    # box_props is given, fill with a translucent version of the same color.
    row_line = {**({'color': color} if color is not None else {}),
                **(line_props or {})}
    if box_props is None and color is not None:
        box_props = {'facecolor': color, 'alpha': fill_alpha}
    line_width = row_line.get('linewidth', row_line.get('lw', 1.0))
    position = 1.0
    draw_pavement(
        values, position=position, width=width,
        tassel_extent=tassel_extent, show_tassels=show_tassels,
        show_box=show_box,
        orientation=orientation, line_props=row_line or None,
        box_props=box_props, data=data, ax=ax)
    # Fit the view tightly to the drawn geometry. The perpendicular extent
    # is the largest tick reach (a tassel, where present, else the box
    # half-width); the value extent is the box span.
    spec = row_spec(values, position, width, orientation,
                    tassel_extent, show_tassels)
    reach = max(t.reach for t in spec.ticks)
    value_extent = (spec.value_high - spec.value_low) or 1.0
    pos_extent = 2 * reach
    # A box edge sitting on the image boundary would otherwise lose half
    # its stroke to clipping. Expand each limit by the half-stroke width
    # in data units so flush edges show full thickness: a point is 1/72
    # inch, the axes fills the figure, so data-per-inch is extent/inches
    # (the dpi cancels). Any *pad* is extra breathing room on top.
    value_inches, pos_inches = (figsize if orientation == 'horizontal'
                                else (figsize[1], figsize[0]))
    value_margin = line_width * value_extent / (144 * value_inches) \
        + pad * value_extent
    pos_margin = line_width * pos_extent / (144 * pos_inches) \
        + pad * pos_extent
    value_lim = (spec.value_low - value_margin, spec.value_high + value_margin)
    pos_lim = (position - reach - pos_margin, position + reach + pos_margin)
    if orientation == 'horizontal':
        ax.set_xlim(value_lim)
        ax.set_ylim(pos_lim)
    else:
        ax.set_ylim(value_lim)
        ax.set_xlim(pos_lim)
    ax.set_axis_off()
    if transparent:
        fig.patch.set_alpha(0.0)
    if path is not None:
        fig.savefig(path, dpi=dpi, transparent=transparent, pad_inches=0)
    return fig

margin

margin(
    data: Iterable[float],
    axis: Literal["x", "y"] = "x",
    where: str | None = None,
    bins: int | None = 4,
    weights: Sequence[float] | None = None,
    pad: float = 0.03,
    size: float = 0.04,
    expand_margins: bool = True,
    show_tassels: bool = False,
    show_box: bool | None = None,
    line_props: Mapping[str, Any] | None = None,
    box_props: Mapping[str, Any] | None = None,
    clip_on: bool = False,
    ax: Axes | None = None,
) -> dict[str, Any]

Draw a pavement plot in the margin of an existing plot.

A marginal pavement is a richer drop-in for a rug plot: it shows the 1D distribution of one variable as a thin strip just outside the axes frame, aligned with the data on that axis.

The strip is drawn with a blended transform, so it stays pinned to the edge at a fixed thickness — the same for x- and y-axis marginals — regardless of the data range on the other axis. By default it sits just outside the frame (clip_on is False) on the side opposite the tick labels — above the axes for axis='x', to the right for axis='y'. The where argument moves it to any edge, inside or outside the frame.

Call this after the main plot so the data-axis limits are already set; the marginal aligns to whatever limits the axes has when it is drawn. For axis='x' with where='top', if the axes already has a title, it is lifted clear of the marginal — so set the title before calling this.

Parameters:

Name Type Description Default
data iterable of float

The values whose distribution to summarize.

required
axis ('x', 'y')

Which axis the data belongs to. 'x' draws a horizontal pavement; 'y' draws a vertical one.

'x'
where str

Which side of the axes to place the strip on, optionally prefixed with 'inside' or 'outside' (e.g. 'bottom', 'inside top', 'outside left'). The side is 'top' or 'bottom' for axis='x' and 'left' or 'right' for axis='y', defaulting to 'top'/'right'. The prefix defaults to 'outside' — the strip sits beyond the frame, and beyond the tick labels on the label side — while 'inside' places it just within the frame, overlapping the data.

None
bins int or None

Number of equal-mass bins. None shows all the data instead of binning it (see pavement_stats), turning the marginal into a true rug.

4
weights sequence of float

Positive weights parallel to data.

None
pad float

Gap between the axes frame and the box, as a fraction of the axes height (aspect-scaled for y-axis marginals, like size).

0.03
size float

Thickness of the box, as a fraction of the axes height. y-axis marginals are scaled by the axes aspect ratio so they render at the same physical thickness as x-axis ones.

0.04
expand_margins bool

For an 'inside' placement, expand the axes margins on the perpendicular axis so the strip does not overlap the data. No effect for 'outside' placements. Mirrors the argument of the same name on seaborn's rugplot.

True
show_tassels bool

Whether to draw tassel marks at repeated quantile values.

False
show_box bool or None

Whether to draw the two long box edges. None (the default) draws them when binned and omits them for a rug (bins=None), so a rug marginal reads like an ordinary rug plot; True or False forces it.

None
line_props dict

Line2D properties for the box edges. Defaults to {'color': 'black', 'linewidth': 1.0}.

None
box_props dict

If given, a background fill is drawn behind the strip. See draw_pavement for the dict semantics. If None (the default), no background is drawn.

None
clip_on bool

Whether to clip the marginal to the axes box. False (the default) lets it render in the exterior margin.

False
ax matplotlib Axes

Axes to draw on. Defaults to plt.gca().

None

Returns:

Type Description
dict

The artist dict from draw_pavement.

Raises:

Type Description
ValueError

If axis is not 'x' or 'y', or where is not valid for the given axis.

See Also

plot : Draw a pavement in the main data area. draw_pavement : The underlying single-row renderer.

Source code in src/pavement/matplotlib.py
def margin(
    data: Iterable[float],
    axis: Literal['x', 'y'] = 'x',
    where: str | None = None,
    bins: int | None = 4,
    weights: Sequence[float] | None = None,
    pad: float = 0.03,
    size: float = 0.04,
    expand_margins: bool = True,
    show_tassels: bool = False,
    show_box: bool | None = None,
    line_props: Mapping[str, Any] | None = None,
    box_props: Mapping[str, Any] | None = None,
    clip_on: bool = False,
    ax: Axes | None = None,
) -> dict[str, Any]:
    """
    Draw a pavement plot in the margin of an existing plot.

    A marginal pavement is a richer drop-in for a rug plot: it shows
    the 1D distribution of one variable as a thin strip just outside
    the axes frame, aligned with the data on that axis.

    The strip is drawn with a blended transform, so it stays pinned
    to the edge at a fixed thickness — the same for x- and y-axis
    marginals — regardless of the data range on the other axis. By
    default it sits just *outside* the frame (*clip_on* is False) on
    the side opposite the tick labels — above the axes for
    ``axis='x'``, to the right for ``axis='y'``. The *where* argument
    moves it to any edge, inside or outside the frame.

    Call this after the main plot so the data-axis limits are
    already set; the marginal aligns to whatever limits the axes
    has when it is drawn. For ``axis='x'`` with ``where='top'``, if
    the axes already has a title, it is lifted clear of the marginal
    — so set the title before calling this.

    Parameters
    ----------
    data : iterable of float
        The values whose distribution to summarize.
    axis : {'x', 'y'}, default: 'x'
        Which axis the data belongs to. ``'x'`` draws a horizontal
        pavement; ``'y'`` draws a vertical one.
    where : str, optional
        Which side of the axes to place the strip on, optionally
        prefixed with 'inside' or 'outside' (e.g. 'bottom',
        'inside top', 'outside left'). The side is 'top' or 'bottom'
        for ``axis='x'`` and 'left' or 'right' for ``axis='y'``,
        defaulting to 'top'/'right'. The prefix defaults to
        'outside' — the strip sits beyond the frame, and beyond the
        tick labels on the label side — while 'inside' places it
        just within the frame, overlapping the data.
    bins : int or None, default: 4
        Number of equal-mass bins. None shows all the data instead of
        binning it (see `pavement_stats`), turning the marginal into a
        true rug.
    weights : sequence of float, optional
        Positive weights parallel to *data*.
    pad : float, default: 0.03
        Gap between the axes frame and the box, as a fraction of the
        axes height (aspect-scaled for y-axis marginals, like *size*).
    size : float, default: 0.04
        Thickness of the box, as a fraction of the axes height.
        y-axis marginals are scaled by the axes aspect ratio so they
        render at the same physical thickness as x-axis ones.
    expand_margins : bool, default: True
        For an 'inside' placement, expand the axes margins on the
        perpendicular axis so the strip does not overlap the data.
        No effect for 'outside' placements. Mirrors the argument of
        the same name on seaborn's ``rugplot``.
    show_tassels : bool, default: False
        Whether to draw tassel marks at repeated quantile values.
    show_box : bool or None, default: None
        Whether to draw the two long box edges. None (the default) draws
        them when binned and omits them for a rug (``bins=None``), so a
        rug marginal reads like an ordinary rug plot; True or False forces
        it.
    line_props : dict, optional
        Line2D properties for the box edges. Defaults to
        ``{'color': 'black', 'linewidth': 1.0}``.
    box_props : dict, optional
        If given, a background fill is drawn behind the strip. See
        `draw_pavement` for the dict semantics. If None (the
        default), no background is drawn.
    clip_on : bool, default: False
        Whether to clip the marginal to the axes box. False (the
        default) lets it render in the exterior margin.
    ax : matplotlib Axes, optional
        Axes to draw on. Defaults to ``plt.gca()``.

    Returns
    -------
    dict
        The artist dict from `draw_pavement`.

    Raises
    ------
    ValueError
        If *axis* is not 'x' or 'y', or *where* is not valid for
        the given *axis*.

    See Also
    --------
    plot : Draw a pavement in the main data area.
    draw_pavement : The underlying single-row renderer.
    """
    if ax is None:
        ax = plt.gca()
    sides = {'x': ('top', 'bottom'), 'y': ('right', 'left')}
    if axis not in sides:
        raise ValueError(f"axis must be 'x' or 'y', got {axis!r}")
    if where is None:
        placement, side = 'outside', sides[axis][0]
    else:
        parts = where.split()
        if len(parts) == 1:
            placement, side = 'outside', parts[0]
        elif len(parts) == 2:
            placement, side = parts
        else:
            raise ValueError(
                "where must be a side, optionally prefixed with "
                f"'inside' or 'outside'; got {where!r}")
        if placement not in ('inside', 'outside'):
            raise ValueError(
                f"where prefix must be 'inside' or 'outside', got {placement!r}")
        if side not in sides[axis]:
            raise ValueError(
                f"where side for axis={axis!r} must be one of "
                f"{sides[axis]}, got {side!r}")
    if axis == 'x':
        transform = blended_transform_factory(ax.transData, ax.transAxes)
        orientation: Literal['vertical', 'horizontal'] = 'horizontal'
        box_size, box_pad = size, pad
    else:
        transform = blended_transform_factory(ax.transAxes, ax.transData)
        orientation = 'vertical'
        # A y-axis strip's thickness is an axes-fraction of width, an
        # x-axis strip's of height. Scale by the axes aspect ratio so
        # both render at the same physical thickness.
        aspect = ax.bbox.height / ax.bbox.width
        box_size, box_pad = size * aspect, pad * aspect
    # Match the package-wide tassel proportion: the spark defaults reach
    # tassel_extent past the box *half*-width, a ratio of 0.05/(0.6/2) = 1/6.
    # Here the box thickness is box_size (its half is box_size/2), so
    # box_size/12 gives that same 1/6-of-the-half reach.
    tassel_extent = box_size / 12
    # Place the box. The far edge is the axes-fraction 1.0 side
    # (top/right); into_axes points from that edge toward the interior.
    far_edge = side in ('top', 'right')
    edge = 1.0 if far_edge else 0.0
    into_axes = -1.0 if far_edge else 1.0
    if placement == 'inside':
        position = edge + into_axes * (box_pad + box_size/2)
    else:  # outside; on the near edge, also clear the tick labels
        clearance = 0.0 if far_edge else _TICK_LABEL_CLEARANCE
        position = edge - into_axes * (clearance + box_pad + box_size/2)
    values = pavement_stats(data, bins=bins, weights=weights)
    props = {**(line_props or {}), 'transform': transform, 'clip_on': clip_on}
    bprops = None
    if box_props is not None:
        bprops = {**box_props, 'transform': transform, 'clip_on': clip_on}
    result = draw_pavement(
        values,
        position=position,
        width=box_size,
        tassel_extent=tassel_extent,
        show_tassels=show_tassels,
        show_box=show_box,
        orientation=orientation,
        line_props=props,
        box_props=bprops,
        data=data,
        ax=ax,
    )
    if placement == 'inside' and expand_margins:
        # Reserve room so the strip doesn't sit on top of the data:
        # the strip's own inward footprint (pad + box + tassel) plus
        # another pad of breathing room on the data side, matching the
        # strip's gap from the frame edge. ax.margins(m) leaves
        # m/(1+2m) of the view empty per side; invert that to cover it.
        footprint = min(2*box_pad + box_size + tassel_extent, 0.45)
        required = footprint / (1 - 2*footprint)
        mx, my = ax.margins()
        if axis == 'x':
            ax.margins(y=max(my, required))
        else:
            ax.margins(x=max(mx, required))
    if (axis == 'x' and side == 'top' and placement == 'outside'
            and ax.get_title()):
        # Lift the title above the marginal (box + tassels) so they
        # don't overlap. Setting _autotitlepos stops matplotlib's
        # auto title positioning from clobbering this on the next draw.
        marginal_top = 1 + box_pad + box_size + tassel_extent
        ax.title.set_y(max(ax.title.get_position()[1], marginal_top + 0.04))
        ax._autotitlepos = False
    return result

draw_pavement2d

draw_pavement2d(
    stats: Mapping[str, Any],
    line_props: Mapping[str, Any] | None = None,
    box_props: Mapping[str, Any] | None = None,
    ax: Axes | None = None,
) -> dict[str, Any]

Draw a 2D pavement from precomputed stats.

Renders every box edge. Where adjacent columns (or rows, for first_split='y') share a boundary along the primary axis, the shared line is drawn once per side, which is invisible for line art but means each side's segment spans only its own column's (or row's) extent along the secondary axis.

Parameters:

Name Type Description Default
stats dict

Output of pavement_stats2d.

required
line_props dict

Line2D properties passed through to Axes.vlines and Axes.hlines. Defaults to {'color': 'black', 'linewidth': 1.0}.

None
box_props dict

If given, a filled ~matplotlib.patches.Rectangle is drawn behind every cell, with these properties (facecolor, alpha, ...) applied uniformly. Defaults to no edge. If None (the default), no fills are drawn.

None
ax matplotlib Axes

Axes to draw on. Defaults to plt.gca().

None

Returns:

Type Description
dict

Maps component name to the artists added to the axes:

  • "fills": list of one background Rectangle per cell, or None if box_props was not given.
  • "verticals", "horizontals": the two LineCollection groups of box edges.
See Also

pavement_stats2d : Compute the stats dict to pass in. plot2d : One-call convenience that combines stats and drawing.

Source code in src/pavement/matplotlib.py
def draw_pavement2d(
    stats: Mapping[str, Any],
    line_props: Mapping[str, Any] | None = None,
    box_props: Mapping[str, Any] | None = None,
    ax: Axes | None = None,
) -> dict[str, Any]:
    """
    Draw a 2D pavement from precomputed stats.

    Renders every box edge. Where adjacent columns (or rows, for
    ``first_split='y'``) share a boundary along the primary axis,
    the shared line is drawn once per side, which is invisible for
    line art but means each side's segment spans only its own
    column's (or row's) extent along the secondary axis.

    Parameters
    ----------
    stats : dict
        Output of `pavement_stats2d`.
    line_props : dict, optional
        Line2D properties passed through to ``Axes.vlines`` and
        ``Axes.hlines``. Defaults to ``{'color': 'black', 'linewidth': 1.0}``.
    box_props : dict, optional
        If given, a filled `~matplotlib.patches.Rectangle` is drawn
        behind every cell, with these properties (facecolor, alpha,
        ...) applied uniformly. Defaults to no edge. If None (the
        default), no fills are drawn.
    ax : matplotlib Axes, optional
        Axes to draw on. Defaults to ``plt.gca()``.

    Returns
    -------
    dict
        Maps component name to the artists added to the axes:

        - ``"fills"``: list of one background `Rectangle` per cell,
          or ``None`` if *box_props* was not given.
        - ``"verticals"``, ``"horizontals"``: the two LineCollection
          groups of box edges.

    See Also
    --------
    pavement_stats2d : Compute the stats dict to pass in.
    plot2d : One-call convenience that combines stats and drawing.
    """
    if ax is None:
        ax = plt.gca()
    props = {'color': 'black', 'linewidth': 1.0, **(line_props or {})}

    primary_edges = stats['primary_edges']
    secondary_edges_per_chunk = stats['secondary_edges_per_chunk']
    first_split = stats['first_split']

    fills = None if box_props is None else []
    rect_props = {'edgecolor': 'none', **(box_props or {})}
    primary_positions = []
    primary_perp_min = []
    primary_perp_max = []
    secondary_positions = []
    secondary_perp_min = []
    secondary_perp_max = []
    for k, sec_edges in enumerate(secondary_edges_per_chunk):
        p_lo, p_hi = primary_edges[k], primary_edges[k + 1]
        s_lo, s_hi = sec_edges[0], sec_edges[-1]
        for p_val in (p_lo, p_hi):
            primary_positions.append(p_val)
            primary_perp_min.append(s_lo)
            primary_perp_max.append(s_hi)
        for s_val in sec_edges:
            secondary_positions.append(s_val)
            secondary_perp_min.append(p_lo)
            secondary_perp_max.append(p_hi)
        if fills is not None:
            # One filled rectangle per cell in this chunk, drawn before
            # the lines so they sit behind them.
            for lo, hi in zip(sec_edges, sec_edges[1:]):
                if first_split == 'x':  # primary is x, secondary is y
                    xy, w, h = (p_lo, lo), p_hi - p_lo, hi - lo
                else:  # primary is y, secondary is x
                    xy, w, h = (lo, p_lo), hi - lo, p_hi - p_lo
                fills.append(ax.add_patch(Rectangle(xy, w, h, **rect_props)))

    if first_split == 'x':
        verticals = ax.vlines(
            primary_positions, primary_perp_min, primary_perp_max, **props)
        horizontals = ax.hlines(
            secondary_positions, secondary_perp_min, secondary_perp_max, **props)
    else:
        horizontals = ax.hlines(
            primary_positions, primary_perp_min, primary_perp_max, **props)
        verticals = ax.vlines(
            secondary_positions, secondary_perp_min, secondary_perp_max, **props)
    return {'fills': fills, 'verticals': verticals, 'horizontals': horizontals}

plot2d

plot2d(
    x: Iterable[float],
    y: Iterable[float],
    weights: Sequence[float] | None = None,
    bins: int = 4,
    x_bins: int | None = None,
    y_bins: int | None = None,
    first_split: Literal["x", "y"] = "x",
    line_props: Mapping[str, Any] | None = None,
    box_props: Mapping[str, Any] | None = None,
    ax: Axes | None = None,
) -> dict[str, Any]

Draw a 2D pavement plot from paired data.

Equivalent to draw_pavement2d(pavement_stats2d(...)). Every cell of the resulting grid holds an equal share of the data.

Parameters:

Name Type Description Default
x iterable of float

Paired coordinates. Must have the same length.

required
y iterable of float

Paired coordinates. Must have the same length.

required
weights sequence of float

Positive weights, one per (x, y) pair.

None
bins int

Default number of bins along each axis.

4
x_bins int

Override bins for the respective axis.

None
y_bins int

Override bins for the respective axis.

None
first_split ('x', 'y')

Which axis to partition first.

'x'
line_props dict

Line2D properties for all box edges. Defaults to {'color': 'black', 'linewidth': 1.0}.

None
box_props dict

If given, a background fill is drawn behind every cell, with these properties applied uniformly. See draw_pavement2d.

None
ax matplotlib Axes

Axes to draw on. Defaults to plt.gca().

None

Returns:

Type Description
dict

The artist dict from draw_pavement2d.

See Also

pavement_stats2d : Compute the stats without drawing. draw_pavement2d : Render from a stats dict. plot : The 1D equivalent.

Source code in src/pavement/matplotlib.py
def plot2d(
    x: Iterable[float],
    y: Iterable[float],
    weights: Sequence[float] | None = None,
    bins: int = 4,
    x_bins: int | None = None,
    y_bins: int | None = None,
    first_split: Literal['x', 'y'] = 'x',
    line_props: Mapping[str, Any] | None = None,
    box_props: Mapping[str, Any] | None = None,
    ax: Axes | None = None,
) -> dict[str, Any]:
    """
    Draw a 2D pavement plot from paired data.

    Equivalent to ``draw_pavement2d(pavement_stats2d(...))``. Every
    cell of the resulting grid holds an equal share of the data.

    Parameters
    ----------
    x, y : iterable of float
        Paired coordinates. Must have the same length.
    weights : sequence of float, optional
        Positive weights, one per (x, y) pair.
    bins : int, default: 4
        Default number of bins along each axis.
    x_bins, y_bins : int, optional
        Override *bins* for the respective axis.
    first_split : {'x', 'y'}, default: 'x'
        Which axis to partition first.
    line_props : dict, optional
        Line2D properties for all box edges. Defaults to
        ``{'color': 'black', 'linewidth': 1.0}``.
    box_props : dict, optional
        If given, a background fill is drawn behind every cell, with
        these properties applied uniformly. See `draw_pavement2d`.
    ax : matplotlib Axes, optional
        Axes to draw on. Defaults to ``plt.gca()``.

    Returns
    -------
    dict
        The artist dict from `draw_pavement2d`.

    See Also
    --------
    pavement_stats2d : Compute the stats without drawing.
    draw_pavement2d : Render from a stats dict.
    plot : The 1D equivalent.
    """
    stats = pavement_stats2d(
        x, y,
        weights=weights,
        bins=bins, x_bins=x_bins, y_bins=y_bins,
        first_split=first_split,
    )
    return draw_pavement2d(stats, line_props=line_props, box_props=box_props,
                           ax=ax)

Inline SVG sparklines (pavement.svg)

Self-contained <svg> strings — no plotting library, no JavaScript. This is where summary, spark, tally, and proportion live.

svg

Inline, interactive pavement sparklines as self-contained SVG.

Where pavement.matplotlib.spark renders a borderless pavement to a raster image for print, this backend emits the same idea as a string of SVG you can drop straight into HTML, Markdown, or an email — no plotting library, no JavaScript bundle, no CDN. The returned <svg> is the artifact.

That makes it the natural fit for an inline sparkline:

  • Vector and themeable. Lines default to currentColor, so the spark inherits the surrounding text color (dark mode included), and it stays crisp at any size. vector-effect="non-scaling-stroke" keeps the box outline a constant width however small the spark is scaled.
  • Sizes with the text. By default the root carries height: 1em; width: auto, so spark(values) drops into a sentence and tracks the font size like a word.
  • Interactive with zero JavaScript. Every equal-mass bin is a hover target carrying its value range, percentile band, and value share in a native <title> tooltip — the same hover text the Bokeh and Plotly backends show — and each quantile tick carries its single value. A small rug (bins=None) makes every value hoverable; a dense one falls back to a single whole-spark summary instead (see tick_hover_limit) — a spark is read value-by-value or summarised, never both. An inline <style> adds CSS hover feedback: the bin or value line under the cursor highlights, signalling the interactivity. Both optional.

The pavement itself is single-row only (spark); richer multi-row or marginal pavements belong to the matplotlib and interactive backends. Alongside it live the column-summary strips tally and proportion, and summary, which composes all three into a whole-dataframe table. The shared geometry comes from pavement._geometry, so an SVG spark lines up box-for-box with the other backends.

Examples:

>>> import pavement.svg as psvg
>>> markup = psvg.spark([1, 2, 3, 4, 5])          # an <svg>...</svg> string
>>> psvg.spark(values, color="steelblue", path="spark.svg")

Summary

The result of summary: an HTML table that renders inline in Jupyter.

Showing it in a notebook — or any tool honoring _repr_html_ — displays the table; str() returns the same HTML fragment, for embedding elsewhere. (summary's path= writes a standalone document to disk.)

Source code in src/pavement/svg.py
class Summary:
    """The result of `summary`: an HTML table that renders inline in Jupyter.

    Showing it in a notebook — or any tool honoring ``_repr_html_`` — displays
    the table; ``str()`` returns the same HTML fragment, for embedding
    elsewhere. (`summary`'s ``path=`` writes a standalone document to disk.)
    """

    def __init__(self, html: str) -> None:
        self.html = html

    def _repr_html_(self) -> str:
        return self.html

    def __str__(self) -> str:
        return self.html

    __repr__ = __str__

spark

spark(
    data: Iterable[float],
    weights: Sequence[float] | None = None,
    bins: int | None = 4,
    domain: tuple[float, float] | None = None,
    orientation: Literal[
        "vertical", "horizontal"
    ] = "horizontal",
    width: float = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    proportional_representation: bool = False,
    min_representation: float = 0.05,
    show_box: bool | None = None,
    color: str | None = None,
    fill_alpha: float = 0.3,
    line_color: str | None = None,
    line_width: float = 1.2,
    height: str = "1em",
    inline: bool = True,
    hover: bool = True,
    value_format: ValueFormat | None = None,
    tick_hover_limit: int | None = 24,
    highlight: bool = True,
    _view_width: float | None = None,
    class_: str = "pavement-spark",
    path: str | None = None,
) -> str

Render a single pavement as a self-contained inline SVG sparkline.

Returns an <svg>...</svg> string with no external dependencies — paste it into any HTML and it renders, scaling to the surrounding text. Like pavement.matplotlib.spark it draws exactly one distribution (a 1D sequence of values) edge to edge with no axes, and defaults to 'horizontal' so the value axis runs left-to-right.

Parameters:

Name Type Description Default
data iterable of float

The values to summarize as a single pavement row. Missing values (NaN, None, pandas NA/NaT) are dropped, each taking its weight with it. Besides plain numbers, an ordered non-float family is accepted and projected onto a numeric axis: Decimal; date/datetime (including pandas Timestamp and polars temporals), shown as dates in the tooltips; timedelta (including pandas.Timedelta and polars Duration), shown as durations; and numpy datetime64/timedelta64 arrays (see value_format and _project).

required
weights sequence of float

Positive weights parallel to data.

None
bins int or None

Number of equal-mass bins. None shows all the data instead of binning it (see pavement_stats), turning the spark into a rug.

4
domain (float, float) or None

Explicit (lo, hi) extent for the value axis, in projected coordinates (floats, after _project has converted dates etc.). When given, the ticks are positioned as if this were the full value range, so values outside the data's own range leave transparent empty space — useful for aligning multiple strips on a shared axis. When None the axis spans the data's own range.

None
orientation ('vertical', 'horizontal')

Direction of the value axis. 'horizontal' runs values left-to-right, the natural fit for an inline strip.

'vertical'
width float

Box thickness across the row. As with pavement.matplotlib.spark only its ratio to tassel_extent matters — the geometry is stretched to fill the SVG.

0.6
tassel_extent float

How far tassel marks reach beyond the box at repeated values.

0.05
show_tassels bool

Whether to draw tassel marks at repeated quantile values.

False
proportional_representation bool

Turn a rug into a frequency rug: scale each value line's length to how often that value occurs, so the most common value's line spans the full box and the rest reach proportionally less (a value seen half as often draws a line half as long). The lines sit on a shared baseline — the bottom edge for a horizontal rug, the left edge for a vertical one — and grow toward the far edge, like little bars. Only meaningful for a rug, so it requires bins=None and show_tassels=False (a tassel's reach and a frequency's reach would fight); a ValueError otherwise. Counts are unweighted — weights don't apply to a rug (see pavement_stats).

False
min_representation float

Floor on a value line's length under proportional_representation, as a fraction of the full box (so 0.05 keeps every line at least 5% of full length). Keeps a rare value's line from collapsing to an invisible point, the way min_box protects a tiny tally slice. Ignored unless proportional_representation is on.

0.05
show_box bool or None

Whether (and how) to draw the long box edges (the borders parallel to the value axis). None (the default) draws them for a binned spark and omits them for a rug (bins=None), so a rug reads like a plain rug; when drawn, each bin contributes its pair of edges only where it holds one or more data points strictly inside it, so the outline closes around bins whose mass is spread out and gaps open where it clumps onto the value lines. True forces the complete box — the two edges unbroken across the whole value range, rug or binned — for when a solid outline is wanted regardless of where the mass falls. False omits the edges entirely.

None
color str

Any CSS color. Tints the lines and fills each bin translucently (see fill_alpha). Defaults to no fill and currentColor lines, so the spark inherits the text color.

None
fill_alpha float

Opacity of the per-bin fill drawn when color is given.

0.3
line_color str

Color for the box and ticks. Overrides color for the lines; defaults to color if given, else currentColor.

None
line_width float

Stroke width in pixels. Held constant as the spark scales (non-scaling-stroke), so lines stay crisp when shrunk.

1.2
height str

CSS height baked onto the root when inline is True. '1em' makes the spark track the font size; width follows the aspect.

'1em'
inline bool

If True, set height/width/vertical-align on the root so the spark drops into running text and sits on the baseline. If False, omit sizing and leave it to your own CSS.

True
hover bool

If True, add native <title> tooltips (no JavaScript): per bin, its value range, percentile band, and the share of values falling strictly inside it ("1 to 2.5\np0 to p25\n15% (3 of 20 values)"); per tick, its value, percentile, and the share of values falling exactly on it (subject to tick_hover_limit). Every value is counted in exactly one bin or tick. When nothing finer is hoverable — a dense rug — a single whole-spark summary is used instead, so a spark is read value-by-value or summarised, never both. False turns all tooltips off.

True
value_format callable

Function mapping a value to its tooltip display string, e.g. lambda v: f"${v:,.2f}". Applies to the bin value ranges, the per-tick values, and the whole-spark summary; defaults to 3 significant figures (or, for projected date/datetime data, to a date renderer). If given, it overrides that default and receives the projected numeric value for non-float input (POSIX seconds for dates, float for Decimal).

None
tick_hover_limit int or None

Cap on how many ticks (distinct values) get their own per-value tooltip. At or below it, each tick is individually hoverable; a denser spark — typically a large rug — falls back to just the summary tooltip, since hundreds of overlapping hit-areas are both unhelpful and heavy. None lifts the cap (hover every value, however many); 0 disables per-tick hover entirely. Binned sparks have only bins + 1 ticks, so the default rarely affects them.

24
highlight bool

If True, add a scoped <style> with pure-CSS hover feedback: the bin under the cursor brightens and a hovered value line thickens — visible cues that the spark is interactive.

True
class_ str

CSS class on the root <svg>, a hook for your own styling.

'pavement-spark'
path str

If given, also write the markup here. A .html/.htm path is wrapped in a minimal standalone document; any other suffix (e.g. .svg) is written as-is. The string is returned either way.

None

Returns:

Type Description
str

The <svg>...</svg> markup.

See Also

pavement.matplotlib.spark : The raster (PNG) counterpart, for print. pavement_stats : Compute the quantile values a spark draws.

Source code in src/pavement/svg.py
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
def spark(
    data: Iterable[float],
    weights: Sequence[float] | None = None,
    bins: int | None = 4,
    domain: tuple[float, float] | None = None,
    orientation: Literal['vertical', 'horizontal'] = 'horizontal',
    width: float = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    proportional_representation: bool = False,
    min_representation: float = 0.05,
    show_box: bool | None = None,
    color: str | None = None,
    fill_alpha: float = 0.3,
    line_color: str | None = None,
    line_width: float = 1.2,
    height: str = '1em',
    inline: bool = True,
    hover: bool = True,
    value_format: ValueFormat | None = None,
    tick_hover_limit: int | None = 24,
    highlight: bool = True,
    _view_width: float | None = None,
    class_: str = 'pavement-spark',
    path: str | None = None,
) -> str:
    """
    Render a single pavement as a self-contained inline SVG sparkline.

    Returns an ``<svg>...</svg>`` string with no external dependencies —
    paste it into any HTML and it renders, scaling to the surrounding
    text. Like `pavement.matplotlib.spark` it draws exactly one
    distribution (a 1D sequence of values) edge to edge with no axes, and
    defaults to ``'horizontal'`` so the value axis runs left-to-right.

    Parameters
    ----------
    data : iterable of float
        The values to summarize as a single pavement row. Missing values
        (``NaN``, ``None``, pandas ``NA``/``NaT``) are dropped, each taking
        its weight with it. Besides plain numbers, an ordered non-float
        family is accepted and projected onto a numeric axis: ``Decimal``;
        ``date``/``datetime`` (including pandas ``Timestamp`` and polars
        temporals), shown as dates in the tooltips; ``timedelta`` (including
        ``pandas.Timedelta`` and polars ``Duration``), shown as durations;
        and numpy ``datetime64``/``timedelta64`` arrays (see *value_format*
        and `_project`).
    weights : sequence of float, optional
        Positive weights parallel to *data*.
    bins : int or None, default: 4
        Number of equal-mass bins. None shows all the data instead of
        binning it (see `pavement_stats`), turning the spark into a rug.
    domain : (float, float) or None, default: None
        Explicit ``(lo, hi)`` extent for the value axis, in projected
        coordinates (floats, after `_project` has converted dates etc.).
        When given, the ticks are positioned as if this were the full
        value range, so values outside the data's own range leave
        transparent empty space — useful for aligning multiple strips on
        a shared axis. When None the axis spans the data's own range.
    orientation : {'vertical', 'horizontal'}, default: 'horizontal'
        Direction of the value axis. 'horizontal' runs values
        left-to-right, the natural fit for an inline strip.
    width : float, default: 0.6
        Box thickness across the row. As with `pavement.matplotlib.spark`
        only its ratio to *tassel_extent* matters — the geometry is
        stretched to fill the SVG.
    tassel_extent : float, default: 0.05
        How far tassel marks reach beyond the box at repeated values.
    show_tassels : bool, default: False
        Whether to draw tassel marks at repeated quantile values.
    proportional_representation : bool, default: False
        Turn a rug into a *frequency rug*: scale each value line's length to
        how often that value occurs, so the most common value's line spans the
        full box and the rest reach proportionally less (a value seen half as
        often draws a line half as long). The lines sit on a shared baseline —
        the bottom edge for a horizontal rug, the left edge for a vertical one —
        and grow toward the far edge, like little bars. Only meaningful for a
        rug, so it
        requires ``bins=None`` and ``show_tassels=False`` (a tassel's reach
        and a frequency's reach would fight); a ``ValueError`` otherwise.
        Counts are unweighted — weights don't apply to a rug (see
        `pavement_stats`).
    min_representation : float, default: 0.05
        Floor on a value line's length under *proportional_representation*, as
        a fraction of the full box (so ``0.05`` keeps every line at least 5% of
        full length). Keeps a rare value's line from collapsing to an
        invisible point, the way *min_box* protects a tiny tally slice. Ignored
        unless *proportional_representation* is on.
    show_box : bool or None, default: None
        Whether (and how) to draw the long box edges (the borders parallel
        to the value axis). None (the default) draws them for a binned spark
        and omits them for a rug (``bins=None``), so a rug reads like a plain
        rug; when drawn, each bin contributes its pair of edges only where it
        holds one or more data points strictly inside it, so the outline
        closes around bins whose mass is spread out and gaps open where it
        clumps onto the value lines. ``True`` forces the *complete* box — the
        two edges unbroken across the whole value range, rug or binned — for
        when a solid outline is wanted regardless of where the mass falls.
        ``False`` omits the edges entirely.
    color : str, optional
        Any CSS color. Tints the lines and fills each bin translucently
        (see *fill_alpha*). Defaults to no fill and ``currentColor``
        lines, so the spark inherits the text color.
    fill_alpha : float, default: 0.3
        Opacity of the per-bin fill drawn when *color* is given.
    line_color : str, optional
        Color for the box and ticks. Overrides *color* for the lines;
        defaults to *color* if given, else ``currentColor``.
    line_width : float, default: 1.2
        Stroke width in pixels. Held constant as the spark scales
        (``non-scaling-stroke``), so lines stay crisp when shrunk.
    height : str, default: '1em'
        CSS height baked onto the root when *inline* is True. ``'1em'``
        makes the spark track the font size; width follows the aspect.
    inline : bool, default: True
        If True, set ``height``/``width``/``vertical-align`` on the root
        so the spark drops into running text and sits on the baseline.
        If False, omit sizing and leave it to your own CSS.
    hover : bool, default: True
        If True, add native ``<title>`` tooltips (no JavaScript): per bin,
        its value range, percentile band, and the share of values falling
        strictly inside it (``"1 to 2.5\\np0 to p25\\n15% (3 of 20
        values)"``); per tick, its value, percentile, and the share of
        values falling exactly on it (subject to *tick_hover_limit*). Every
        value is counted in exactly one bin or tick. When nothing finer is
        hoverable — a dense rug — a single whole-spark summary is used
        instead, so a spark is read value-by-value or summarised, never
        both. False turns all tooltips off.
    value_format : callable, optional
        Function mapping a value to its tooltip display string, e.g.
        ``lambda v: f"${v:,.2f}"``. Applies to the bin value ranges, the
        per-tick values, and the whole-spark summary; defaults to 3
        significant figures (or, for projected ``date``/``datetime`` data, to
        a date renderer). If given, it overrides that default and receives the
        *projected* numeric value for non-float input (POSIX seconds for
        dates, ``float`` for ``Decimal``).
    tick_hover_limit : int or None, default: 24
        Cap on how many ticks (distinct values) get their own per-value
        tooltip. At or below it, each tick is individually hoverable; a
        denser spark — typically a large rug — falls back to just the
        summary tooltip, since hundreds of overlapping hit-areas are both
        unhelpful and heavy. None lifts the cap (hover every value,
        however many); 0 disables per-tick hover entirely. Binned sparks
        have only ``bins + 1`` ticks, so the default rarely affects them.
    highlight : bool, default: True
        If True, add a scoped ``<style>`` with pure-CSS hover feedback:
        the bin under the cursor brightens and a hovered value line
        thickens — visible cues that the spark is interactive.
    class_ : str, default: 'pavement-spark'
        CSS class on the root ``<svg>``, a hook for your own styling.
    path : str, optional
        If given, also write the markup here. A ``.html``/``.htm`` path
        is wrapped in a minimal standalone document; any other suffix
        (e.g. ``.svg``) is written as-is. The string is returned either
        way.

    Returns
    -------
    str
        The ``<svg>...</svg>`` markup.

    See Also
    --------
    pavement.matplotlib.spark : The raster (PNG) counterpart, for print.
    pavement_stats : Compute the quantile values a spark draws.
    """
    # Drop missing values (each taking its weight with it) before anything
    # looks at the data: `_project` picks its branch from the first value,
    # the frequency rug counts occurrences, and the aria-label and summary
    # tooltip report a value count — all of which should see the present
    # values only, mirroring `pavement_stats`.
    data = list(data)
    if weights is not None:
        if len(weights) != len(data):
            raise ValueError(
                f"weights has length {len(weights)}, expected {len(data)}")
        kept = [(v, w) for v, w in zip(data, weights) if not _is_missing(v)]
        data = [v for v, _ in kept]
        weights = [w for _, w in kept]
    else:
        data = [v for v in data if not _is_missing(v)]
    n = len(data)
    if n == 0:
        raise ValueError("data must be non-empty")
    if proportional_representation and (bins is not None or show_tassels):
        raise ValueError(
            "proportional_representation requires a rug: bins=None and "
            "show_tassels=False")
    # Project an ordered non-float family (Decimal, date/datetime) onto a
    # numeric axis, taking its renderer as the default tooltip format. A
    # caller-supplied value_format still wins (and then receives the projected
    # value); real numbers pass through unchanged.
    data, projected_format = _project(data)
    value_format = value_format or projected_format
    values = pavement_stats(data, bins=bins, weights=weights)
    position = 1.0
    spec = row_spec(values, position, width, orientation,
                    tassel_extent, show_tassels, value_format, data=data)
    fmt_value = value_format or fmt
    reach = max(t.reach for t in spec.ticks)
    half = spec.half

    # A frequency rug scales each value line's *drawn* length to how common
    # that value is — the most common reaches the full box, the rest less, but
    # never below `min_representation` of full so a rare value stays visible.
    # The lines are anchored on a baseline edge below (see the tick loop), and
    # the box thickness `reach` is unchanged (every tick's geometric reach is
    # still `half`), so the viewBox isn't distorted. Without the flag, each line
    # is drawn at its full geometric reach, exactly as before.
    if proportional_representation:
        freq = Counter(data)
        top = max(freq.values())
        mark_reaches = [t.reach * max(freq[t.value] / top, min_representation)
                        for t in spec.ticks]
    else:
        mark_reaches = [t.reach for t in spec.ticks]

    value_low, value_high = spec.value_low, spec.value_high
    if domain is not None:
        value_low, value_high = domain
    if value_high == value_low:  # constant data: give the box a little span
        value_low, value_high = value_low - 0.5, value_high + 0.5
    value_range = value_high - value_low
    perp_low, perp_high = position - reach, position + reach
    perp_range = perp_high - perp_low

    horizontal = orientation == 'horizontal'
    view_w, view_h = _VIEWBOX[orientation]
    # `_view_width` (internal) overrides the value-axis viewBox extent without
    # touching the on-screen size (set by CSS).  All geometry is fractional, so
    # this is visually identical EXCEPT that it sets the viewBox aspect: matching
    # it to a stretched cell's width:height ratio keeps `non-scaling-stroke`
    # strokes uniform instead of fattening the cross-axis lines (see `summary`).
    if _view_width is not None and horizontal:
        view_w = _view_width

    def pt(perp: float, value: float) -> tuple[float, float]:
        """Map (perpendicular, value) geometry onto SVG (x, y), filling
        the viewBox. SVG y grows downward, so the value axis is flipped
        for a vertical spark to keep larger values toward the top."""
        fv = (value - value_low) / value_range
        fp = (perp - perp_low) / perp_range
        if horizontal:
            return fv * view_w, fp * view_h
        return fp * view_w, (1.0 - fv) * view_h

    line_paint = line_color or color or 'currentColor'
    fill_paint = color or 'currentColor'
    rest_opacity = fill_alpha if color is not None else 0.0

    def stroke_line(x0: float, y0: float, x1: float, y1: float, *,
                    attrs: str = '', child: str = '') -> str:
        coords = (f'<line{attrs} x1="{_num(x0)}" y1="{_num(y0)}" '
                  f'x2="{_num(x1)}" y2="{_num(y1)}"')
        return f'{coords}>{child}</line>' if child else f'{coords}/>'

    parts: list[str] = []

    def rect(x0: float, y0: float, x1: float, y1: float, *,
             cls: str = '', extra: str = '', child: str = '') -> str:
        x, y = min(x0, x1), min(y0, y1)
        return (f'<rect{cls} x="{_num(x)}" y="{_num(y)}" '
                f'width="{_num(abs(x1 - x0))}" height="{_num(abs(y1 - y0))}" '
                f'{extra}>{child}</rect>')

    # Whether each value line is individually hoverable. While the ticks
    # stay few enough to be worth hovering one by one (binned sparks
    # always are; a small rug is, a dense one isn't), each gets a
    # transparent wide hit-area and a value tooltip; a denser rug skips
    # this and leans on the whole-spark summary instead. Counting the
    # ticks (distinct values) rather than the raw data keeps repeats from
    # inflating the total. (Defined here because the rug's gap boxes below
    # appear under the same condition.)
    per_tick_hover = hover and (
        tick_hover_limit is None or len(spec.ticks) <= tick_hover_limit)

    # A rug, read value by value, also gets the boxes between its lines — the
    # gaps between consecutive distinct values — as hover targets, just like a
    # pavement's bins; a dense rug (ticks past the limit) keeps only its
    # whole-spark summary instead, to stay light.
    rug_gap_boxes = bins is None and per_tick_hover

    if bins is not None or rug_gap_boxes:
        # Equal-mass bins — or, for a rug, the boxes spanning the gaps between
        # distinct values: a translucent (or invisible) rect each, spanning the
        # box thickness. Drawn first so they sit behind the lines, and they
        # double as hover targets — the value-range/percentile/count tooltip
        # and the CSS highlight both attach here. A rug's gap boxes carry a
        # zero interior count (nothing falls strictly between two adjacent
        # values), but they make a wide stretch as easy to hover as a value
        # line is hard to hit; `hover_bins` drops the zero-width bins at a
        # rug's repeated values, which coincide with the tick lines.
        for b in hover_bins(spec, bins is None):
            x0, y0 = pt(position - half, b.low)
            x1, y1 = pt(position + half, b.high)
            # value range, then the percentile band, then the count — the
            # shared order; but an empty box (a rug gap, or a bin whose mass
            # sits on its edges) drops the band, which would otherwise read as
            # a misleading "pNN to pNN" over a stretch holding no data. Mirrors
            # the tick label's conditional percentile.
            lines = [b.value_range]
            if b.band and (b.inside or not b.count):
                lines.append(b.band)
            if b.count:
                lines.append(b.count)
            title = (f'<title>{escape(chr(10).join(lines))}</title>'
                     if hover else '')
            parts.append(rect(
                x0, y0, x1, y1, cls=' class="pvbin"',
                extra=f'fill="{fill_paint}" fill-opacity="{_num(rest_opacity)}" '
                      f'pointer-events="all"', child=title))
    elif color is not None:
        # A rug with no gap boxes (dense, or per-tick hover disabled) but a
        # requested color still fills the box as a single background.
        x0, y0 = pt(position - half, value_low)
        x1, y1 = pt(position + half, value_high)
        parts.append(rect(
            x0, y0, x1, y1,
            extra=f'fill="{fill_paint}" fill-opacity="{_num(fill_alpha)}" '
                  f'pointer-events="none"'))

    # All the value strokes share their styling, so it lives once on a
    # parent <g> and each line is just coordinates — keeps a dense rug
    # compact. The long box edges run along the value axis at each side;
    # then one tick per distinct value, reaching past the box as a tassel
    # where the value repeats (and closing the box ends at the extremes).
    # A hoverable tick pairs its visible mark with a transparent hit-area
    # inside a <g class="pvtick">, so CSS can thicken the mark on hover.
    #
    # `box_edge_spans` resolves where the long edges go (shared with every
    # other backend): by default each populated bin closes over itself and
    # gaps open where the mass clumps onto a value line; show_box=True forces
    # one unbroken span; show_box=False (and, by default, a rug) draws none.
    marks: list[str] = []
    for low, high in box_edge_spans(spec, show_box):
        marks += [stroke_line(*pt(side, low), *pt(side, high))
                  for side in (position - half, position + half)]
    # A frequency rug anchors its lines on one box edge (the *baseline*) and
    # grows them inward by their frequency-scaled length, so they read like
    # little bars rising from a shared line rather than floating symmetrically
    # across the value axis (which proved hard to read). The baseline is the
    # bottom edge for a horizontal rug and the left edge for a vertical one, and
    # a line grows the full box thickness (`2 * mark_reach`) toward the far
    # edge. In perpendicular coordinates the bottom is `position + half` (it
    # maps to the largest y) and the left is `position - half` (the smallest x),
    # so the two orientations anchor at opposite perp ends and grow opposite
    # ways. A plain rug or pavement keeps the symmetric, axis-centered marks.
    base_perp = position + half if horizontal else position - half
    grow = -1.0 if horizontal else 1.0
    for t, mark_reach in zip(spec.ticks, mark_reaches):
        # The visible mark uses the (possibly frequency-scaled) reach; the
        # transparent hit-area keeps the full reach so even a short line stays
        # easy to hover. They coincide unless proportional_representation is on.
        if proportional_representation:
            a = pt(base_perp, t.value)
            b = pt(base_perp + grow * 2 * mark_reach, t.value)
        else:
            a = pt(position - mark_reach, t.value)
            b = pt(position + mark_reach, t.value)
        if per_tick_hover:
            ha = pt(position - t.reach, t.value)
            hb = pt(position + t.reach, t.value)
            # value first, then the percentile cut point (absent for a
            # single-value spark), then the count/share — the shared order.
            label = t.value_str
            if t.quantile:
                label += chr(10) + t.quantile
            label += chr(10) + t.count
            marks.append(
                '<g class="pvtick">'
                + stroke_line(*a, *b, attrs=' class="pvmark"')
                + stroke_line(*ha, *hb, attrs=' class="pvhit" '
                              'stroke="transparent" '
                              f'stroke-width="{_num(_HIT_WIDTH)}" '
                              'pointer-events="all"',
                              child=f'<title>{escape(label)}</title>')
                + '</g>')
        else:
            marks.append(stroke_line(*a, *b))
    parts.append(
        f'<g stroke="{line_paint}" stroke-width="{_num(line_width)}" '
        f'fill="none" vector-effect="non-scaling-stroke" '
        f'pointer-events="none">{"".join(marks)}</g>')

    # CSS hover feedback (pure, scoped): the bin under the cursor brightens
    # its fill, and a hovered value line thickens — both signalling that
    # the spark is interactive. Harmless when an element isn't present.
    style = ''
    if highlight:
        selector = '.' + '.'.join(class_.split())
        hover_opacity = min(1.0, fill_alpha + 0.2) if color is not None else 0.13
        thick = _num(line_width * 2)
        style = (
            f'<style>'
            f'{selector} .pvbin{{transition:fill-opacity .1s ease}}'
            f'{selector} .pvbin:hover{{fill-opacity:{_num(hover_opacity)}}}'
            f'{selector} .pvmark{{transition:stroke-width .1s ease}}'
            f'{selector} .pvtick:hover .pvmark{{stroke-width:{thick}}}'
            f'</style>')

    root_style = 'overflow:visible;'  # let flush edges show their full stroke
    if inline:
        root_style += f'height:{height};width:auto;vertical-align:-0.15em;'
    label = f"pavement sparkline of {n} value{'' if n == 1 else 's'}"
    # A whole-spark summary tooltip — but only when nothing finer is
    # hoverable, so a spark is either summarised as a whole or read value
    # by value, never both. That means a dense rug (no bins, ticks past
    # the limit); a binned or small-rug spark relies on its own per-bin /
    # per-value tooltips instead.
    root_title = ''
    if hover and bins is None and not per_tick_hover:
        summary = (f"{n} value{'' if n == 1 else 's'}, "
                   f"{fmt_value(spec.value_low)} to {fmt_value(spec.value_high)}")
        root_title = f'<title>{escape(summary)}</title>'
    svg = (
        f'<svg xmlns="http://www.w3.org/2000/svg" '
        f'viewBox="0 0 {_num(view_w)} {_num(view_h)}" '
        f'preserveAspectRatio="none" class={quoteattr(class_)} '
        f'role="img" aria-label={quoteattr(label)} '
        f'style={quoteattr(root_style)}>'
        f'{root_title}<desc>{escape(label)}</desc>{style}'
        f'{"".join(parts)}</svg>')

    if path is not None:
        document = svg
        if path.endswith(('.html', '.htm')):
            document = ('<!doctype html><meta charset="utf-8">'
                        f'<body>{svg}</body>')
        with open(path, 'w', encoding='utf-8') as f:
            f.write(document)
    return svg

proportion

proportion(
    data: Iterable[object],
    orientation: Literal[
        "vertical", "horizontal"
    ] = "horizontal",
    colors: Sequence[str] = _PROP_COLORS,
    other_color: str = _PROP_OTHER,
    max_boxes: int = 12,
    min_box: float = 3.0,
    catchall_tolerance: float = 0.1,
    value_crop: int | None = 128,
    line_color: str | None = None,
    line_width: float = 1.0,
    height: str = "1em",
    inline: bool = True,
    hover: bool = True,
    highlight: bool = True,
    class_: str = "pavement-proportion",
    path: str | None = None,
) -> str

Render a column's value counts as a self-contained inline SVG strip.

A companion to tally in the same borderless form factor, visualizing a column's value distribution the way pandas value_counts() reports it. One box per value, left to right in descending frequency, each sized in proportion to how often that value occurs. It fills the gap a pavement spark leaves for categorical columns, which have no numeric distribution to draw. Missing values are dropped (see proportion_stats).

High-cardinality columns are kept legible: at most max_boxes values get their own box, and the rest are lumped into a single catch-all box on the right. The cutoff comes sooner than max_boxes if a long tail of tiny boxes would otherwise squeeze the catch-all into a misleading width (see catchall_tolerance). Boxes never fall below min_box, so even a rare value stays visible and hoverable.

Parameters:

Name Type Description Default
data iterable

The column's values, of any type (see proportion_stats).

required
orientation ('vertical', 'horizontal')

Box layout. 'horizontal' runs the boxes left-to-right (most common first); 'vertical' stacks them top-to-bottom in the same order.

'vertical'
colors sequence of str

CSS colors cycled across the value boxes (so adjacent boxes differ).

a dark/light blue pair
other_color str

CSS color of the catch-all box, when present.

_PROP_OTHER
max_boxes int

Most values to draw individually before lumping the rest into the catch-all.

12
min_box float

Smallest on-screen length of any box, in viewBox units (the strip is 140 long). Keeps a rare value visible and hoverable; the shortfall is taken proportionally from the larger boxes (see tally).

3.0
catchall_tolerance float

How far the catch-all's drawn width may stray from its true proportion, as a fraction, before the individual-box cutoff is moved earlier (lumping more into the catch-all). Smaller is stricter.

0.1
value_crop int or None

Cap on a value's length in its tooltip; longer values are truncated with an ellipsis. None disables cropping.

128
line_color str or None

Optional hairline outlining each box; None (the default) leaves the boxes borderless, separated by their fills, like tally.

None
line_width float

Outline stroke width in pixels (only when line_color is given).

1.0
height str

CSS height baked onto the root when inline is True.

'1em'
inline bool

If True, size the root so the strip drops into running text.

True
hover bool

If True, give each box a <title> tooltip — its value, then its share and count, e.g. 'dog\n10% (10 of 100 values)'. The catch-all reports the lumped share and how many distinct values it covers. False turns tooltips off.

True
highlight bool

If True, add a scoped <style> that brightens the box under the cursor.

True
class_ str

CSS class on the root <svg>.

'pavement-proportion'
path str

If given, also write the markup here (.html/.htm wrapped in a minimal document; any other suffix as-is). The string is returned either way.

None

Returns:

Type Description
str

The <svg>...</svg> markup.

Raises:

Type Description
ValueError

If data has no non-missing values to summarize.

See Also

tally : The distinct/duplicate/missing companion strip. pavement.core.proportion_stats : The value counts it draws.

Source code in src/pavement/svg.py
def proportion(
    data: Iterable[object],
    orientation: Literal['vertical', 'horizontal'] = 'horizontal',
    colors: Sequence[str] = _PROP_COLORS,
    other_color: str = _PROP_OTHER,
    max_boxes: int = 12,
    min_box: float = 3.0,
    catchall_tolerance: float = 0.1,
    value_crop: int | None = 128,
    line_color: str | None = None,
    line_width: float = 1.0,
    height: str = '1em',
    inline: bool = True,
    hover: bool = True,
    highlight: bool = True,
    class_: str = 'pavement-proportion',
    path: str | None = None,
) -> str:
    """
    Render a column's value counts as a self-contained inline SVG strip.

    A companion to `tally` in the same borderless form factor, visualizing a
    column's value distribution the way pandas ``value_counts()`` reports it.
    One box per value, left to right in descending frequency, each sized in
    proportion to how often that value occurs. It fills the gap a pavement
    spark leaves for categorical columns, which have no numeric distribution
    to draw. Missing values are dropped (see `proportion_stats`).

    High-cardinality columns are kept legible: at most *max_boxes* values get
    their own box, and the rest are lumped into a single catch-all box on the
    right. The cutoff comes sooner than *max_boxes* if a long tail of tiny
    boxes would otherwise squeeze the catch-all into a misleading width (see
    *catchall_tolerance*). Boxes never fall below *min_box*, so even a rare
    value stays visible and hoverable.

    Parameters
    ----------
    data : iterable
        The column's values, of any type (see `proportion_stats`).
    orientation : {'vertical', 'horizontal'}, default: 'horizontal'
        Box layout. 'horizontal' runs the boxes left-to-right (most common
        first); 'vertical' stacks them top-to-bottom in the same order.
    colors : sequence of str, default: a dark/light blue pair
        CSS colors cycled across the value boxes (so adjacent boxes differ).
    other_color : str
        CSS color of the catch-all box, when present.
    max_boxes : int, default: 12
        Most values to draw individually before lumping the rest into the
        catch-all.
    min_box : float, default: 3.0
        Smallest on-screen length of any box, in viewBox units (the strip is
        140 long). Keeps a rare value visible and hoverable; the shortfall is
        taken proportionally from the larger boxes (see `tally`).
    catchall_tolerance : float, default: 0.1
        How far the catch-all's drawn width may stray from its true
        proportion, as a fraction, before the individual-box cutoff is moved
        earlier (lumping more into the catch-all). Smaller is stricter.
    value_crop : int or None, default: 128
        Cap on a value's length in its tooltip; longer values are truncated
        with an ellipsis. None disables cropping.
    line_color : str or None, default: None
        Optional hairline outlining each box; None (the default) leaves the
        boxes borderless, separated by their fills, like `tally`.
    line_width : float, default: 1.0
        Outline stroke width in pixels (only when *line_color* is given).
    height : str, default: '1em'
        CSS height baked onto the root when *inline* is True.
    inline : bool, default: True
        If True, size the root so the strip drops into running text.
    hover : bool, default: True
        If True, give each box a ``<title>`` tooltip — its value, then its
        share and count, e.g. ``'dog\\n10% (10 of 100 values)'``. The
        catch-all reports the lumped share and how many distinct values it
        covers. False turns
        tooltips off.
    highlight : bool, default: True
        If True, add a scoped ``<style>`` that brightens the box under the
        cursor.
    class_ : str, default: 'pavement-proportion'
        CSS class on the root ``<svg>``.
    path : str, optional
        If given, also write the markup here (``.html``/``.htm`` wrapped in a
        minimal document; any other suffix as-is). The string is returned
        either way.

    Returns
    -------
    str
        The ``<svg>...</svg>`` markup.

    Raises
    ------
    ValueError
        If *data* has no non-missing values to summarize.

    See Also
    --------
    tally : The distinct/duplicate/missing companion strip.
    pavement.core.proportion_stats : The value counts it draws.
    """
    stats = proportion_stats(data)
    items = stats['counts']
    total = stats['total']
    if total == 0:
        raise ValueError("data has no non-missing values to summarize")

    horizontal = orientation == 'horizontal'
    view_w, view_h = _VIEWBOX[orientation]
    span = view_w if horizontal else view_h

    counts_only = [count for _, count in items]
    k = len(items)
    shown = _proportion_cutoff(counts_only, total, span, min_box,
                               max_boxes, catchall_tolerance)
    catch_count = total - sum(counts_only[:shown])  # 0 when all shown
    box_counts = counts_only[:shown] + ([catch_count] if catch_count else [])
    lengths = _box_lengths(box_counts, span, min_box)

    stroke = ''
    if line_color is not None:
        stroke = (f' stroke="{line_color}" stroke-width="{_num(line_width)}"'
                  f' vector-effect="non-scaling-stroke"')
    palette = list(colors) or list(_PROP_COLORS)
    noun = 'value' if total == 1 else 'values'

    parts: list[str] = []
    offset = 0.0
    for index, (count, length) in enumerate(zip(box_counts, lengths)):
        is_catch = bool(catch_count) and index == len(box_counts) - 1
        color = other_color if is_catch else palette[index % len(palette)]
        if horizontal:
            x, y, w, h = offset, 0.0, length, view_h
        else:
            x, y, w, h = 0.0, offset, view_w, length
        title = ''
        if hover:
            if is_catch:
                lumped = k - shown
                text = (f"other\n"
                        f"{pct(count, total)} ({count:,} of {total:,} {noun})\n"
                        f"(across {lumped:,} distinct values)")
            else:
                value = str(items[index][0])
                if value_crop is not None and len(value) > value_crop:
                    value = value[:value_crop] + "…"
                text = (f"{value}\n"
                        f"{pct(count, total)} ({count:,} of {total:,} {noun})")
            title = f'<title>{escape(text)}</title>'
        parts.append(
            f'<rect class="tvbox" x="{_num(x)}" y="{_num(y)}" '
            f'width="{_num(w)}" height="{_num(h)}" fill="{color}"{stroke} '
            f'pointer-events="all">{title}</rect>')
        offset += length

    style = ''
    if highlight:
        selector = '.' + '.'.join(class_.split())
        style = (
            f'<style>'
            f'{selector} .tvbox{{transition:filter .1s ease}}'
            f'{selector} .tvbox:hover{{filter:brightness(1.12)}}'
            f'</style>')

    root_style = 'overflow:visible;'
    if inline:
        root_style += f'height:{height};width:auto;vertical-align:-0.15em;'
    label = (f"value proportions of {total} {noun} across {k} distinct "
             f"value{'' if k == 1 else 's'}"
             + (f", top {shown} shown" if catch_count else ""))
    svg = (
        f'<svg xmlns="http://www.w3.org/2000/svg" '
        f'viewBox="0 0 {_num(view_w)} {_num(view_h)}" '
        f'preserveAspectRatio="none" class={quoteattr(class_)} '
        f'role="img" aria-label={quoteattr(label)} '
        f'style={quoteattr(root_style)}>'
        f'<desc>{escape(label)}</desc>{style}'
        f'{"".join(parts)}</svg>')

    if path is not None:
        document = svg
        if path.endswith(('.html', '.htm')):
            document = ('<!doctype html><meta charset="utf-8">'
                        f'<body>{svg}</body>')
        with open(path, 'w', encoding='utf-8') as f:
            f.write(document)
    return svg

tally

tally(
    data: Iterable[object],
    orientation: Literal[
        "vertical", "horizontal"
    ] = "horizontal",
    distinct_color: str = _TALLY_DISTINCT,
    repeated_color: str = _TALLY_REPEATED,
    missing_color: str = _TALLY_MISSING,
    line_color: str | None = None,
    line_width: float = 1.0,
    min_box: float = 3.0,
    fill_ratio: float = 1.0,
    height: str = "1em",
    inline: bool = True,
    hover: bool = True,
    highlight: bool = True,
    noun: str = "entry",
    class_: str = "pavement-tally",
    path: str | None = None,
) -> str

Render a column's make-up as a self-contained inline SVG strip.

A companion to spark with the same form factor and footprint, but a different question. Where a spark summarizes the distribution of a numeric column, a tally summarizes the column itself: three boxes, sized in proportion to how many of the column's values are distinct (leftmost), how many duplicate a value already seen (middle), and how many are missing (rightmost). It works on a column of any type, and surfaces exactly what a pavement plot can't — missing values and distinctness.

By default the three boxes fill the strip edge to edge, since the counts sum to the total (see pavement.core.tally_stats); a category with no values draws no box. Each box carries a native <title> tooltip with its share and count — the lines between boxes do not. Returns an <svg>...</svg> string with no external dependencies; paste it into any HTML and it renders, scaling to the surrounding text.

Parameters:

Name Type Description Default
data iterable

The column's values, of any type (see tally_stats).

required
orientation ('vertical', 'horizontal')

Box layout. 'horizontal' lays the boxes left-to-right (distinct, duplicate, missing); 'vertical' stacks them top-to-bottom in the same order.

'vertical'
distinct_color str

Any CSS color for each box (repeated_color tints the duplicate box). Default to a dark blue, a light blue, and a muted dark red.

_TALLY_DISTINCT
repeated_color str

Any CSS color for each box (repeated_color tints the duplicate box). Default to a dark blue, a light blue, and a muted dark red.

_TALLY_DISTINCT
missing_color str

Any CSS color for each box (repeated_color tints the duplicate box). Default to a dark blue, a light blue, and a muted dark red.

_TALLY_DISTINCT
line_color str or None

Color of an optional hairline outlining each box (and so separating adjacent boxes). The default, None, leaves the boxes borderless, separated by their fills alone. When given, the outline is held at a constant width as the strip scales (non-scaling-stroke).

None
line_width float

Outline stroke width in pixels (only when line_color is given).

1.0
min_box float

Smallest on-screen length a box may have for a category that has any values, in viewBox units (the strip is 140 long, so the default is ~2% of it). Keeps a tiny-but-nonzero slice — a stray missing value among thousands — visible and hoverable instead of collapsing to a hairline; the shortfall is taken proportionally from the larger boxes, and the tooltip still reports the true share and count. 0 makes the boxes purely proportional. A category with no values still draws no box.

3.0
fill_ratio float

Fraction of the strip's span that the boxes occupy, in [0, 1]. At 1.0 (default) the boxes fill edge to edge. Values below 1.0 leave the right (or bottom, when vertical) portion of the strip empty — transparent, with no box drawn. Used by summary to make each group's tally proportional to its row count relative to the total across groups, so a smaller group appears visually narrower. Clamped to [0, 1]; the tooltips always report the true counts.

1.0
height str

CSS height baked onto the root when inline is True, so the strip tracks the font size; width follows the aspect.

'1em'
inline bool

If True, set height/width/vertical-align on the root so the strip drops into running text and sits on the baseline.

True
hover bool

If True, give each box a <title> tooltip — its label, then its share and count, e.g. "distinct\n60% (3 of 5 entries)". The distinct box adds a line for how many of those entries appear exactly once, e.g. "(2 appearing once)". False turns tooltips off.

True
highlight bool

If True, add a scoped <style> that brightens the box under the cursor — a cue that the strip is interactive.

True
noun str

Singular noun for what each entry is, used in the tooltips and the aria-label (e.g. "3 of 5 entries"); pluralized for display (entry -> entries). The default is 'entry' rather than 'value' because the count includes missing entries, which aren't values. summary passes 'row' for the whole-frame tally (entries are rows).

'entry'
class_ str

CSS class on the root <svg>, a hook for your own styling.

'pavement-tally'
path str

If given, also write the markup here. A .html/.htm path is wrapped in a minimal standalone document; any other suffix is written as-is. The string is returned either way.

None

Returns:

Type Description
str

The <svg>...</svg> markup.

Raises:

Type Description
ValueError

If data is empty (no values to summarize).

See Also

spark : The distribution sparkline this strip accompanies. pavement.core.tally_stats : The backend-agnostic counts it draws.

Source code in src/pavement/svg.py
def tally(
    data: Iterable[object],
    orientation: Literal['vertical', 'horizontal'] = 'horizontal',
    distinct_color: str = _TALLY_DISTINCT,
    repeated_color: str = _TALLY_REPEATED,
    missing_color: str = _TALLY_MISSING,
    line_color: str | None = None,
    line_width: float = 1.0,
    min_box: float = 3.0,
    fill_ratio: float = 1.0,
    height: str = '1em',
    inline: bool = True,
    hover: bool = True,
    highlight: bool = True,
    noun: str = 'entry',
    class_: str = 'pavement-tally',
    path: str | None = None,
) -> str:
    """
    Render a column's make-up as a self-contained inline SVG strip.

    A companion to `spark` with the same form factor and footprint, but a
    different question. Where a spark summarizes the *distribution* of a
    numeric column, a tally summarizes the *column itself*: three boxes,
    sized in proportion to how many of the column's values are distinct
    (leftmost), how many duplicate a value already seen (middle), and
    how many are missing (rightmost). It works on a column of any type, and
    surfaces exactly what a pavement plot can't — missing values and
    distinctness.

    By default the three boxes fill the strip edge to edge, since the counts
    sum to the total (see `pavement.core.tally_stats`); a category with no
    values draws no box. Each box carries a native ``<title>`` tooltip with
    its share and count — the lines between boxes do not. Returns an
    ``<svg>...</svg>`` string with no external dependencies; paste it into
    any HTML and it renders, scaling to the surrounding text.

    Parameters
    ----------
    data : iterable
        The column's values, of any type (see `tally_stats`).
    orientation : {'vertical', 'horizontal'}, default: 'horizontal'
        Box layout. 'horizontal' lays the boxes left-to-right
        (distinct, duplicate, missing); 'vertical' stacks them top-to-bottom
        in the same order.
    distinct_color, repeated_color, missing_color : str
        Any CSS color for each box (``repeated_color`` tints the
        ``duplicate`` box). Default to a dark blue, a light blue, and a
        muted dark red.
    line_color : str or None, default: None
        Color of an optional hairline outlining each box (and so separating
        adjacent boxes). The default, None, leaves the boxes borderless,
        separated by their fills alone. When given, the outline is held at a
        constant width as the strip scales (``non-scaling-stroke``).
    line_width : float, default: 1.0
        Outline stroke width in pixels (only when *line_color* is given).
    min_box : float, default: 3.0
        Smallest on-screen length a box may have for a category that has
        *any* values, in viewBox units (the strip is 140 long, so the
        default is ~2% of it). Keeps a tiny-but-nonzero slice — a stray
        missing value among thousands — visible and hoverable instead of
        collapsing to a hairline; the shortfall is taken proportionally
        from the larger boxes, and the tooltip still reports the true share
        and count. 0 makes the boxes purely proportional. A category with no
        values still draws no box.
    fill_ratio : float, default: 1.0
        Fraction of the strip's span that the boxes occupy, in [0, 1]. At
        1.0 (default) the boxes fill edge to edge. Values below 1.0 leave
        the right (or bottom, when vertical) portion of the strip empty —
        transparent, with no box drawn. Used by `summary` to make each
        group's tally proportional to its row count relative to the total
        across groups, so a smaller group appears visually narrower. Clamped
        to [0, 1]; the tooltips always report the true counts.
    height : str, default: '1em'
        CSS height baked onto the root when *inline* is True, so the strip
        tracks the font size; width follows the aspect.
    inline : bool, default: True
        If True, set ``height``/``width``/``vertical-align`` on the root so
        the strip drops into running text and sits on the baseline.
    hover : bool, default: True
        If True, give each box a ``<title>`` tooltip — its label, then its
        share and count, e.g. ``"distinct\\n60% (3 of 5 entries)"``. The
        distinct box adds a line for how many of those entries appear
        exactly once, e.g. ``"(2 appearing once)"``. False turns tooltips
        off.
    highlight : bool, default: True
        If True, add a scoped ``<style>`` that brightens the box under the
        cursor — a cue that the strip is interactive.
    noun : str, default: 'entry'
        Singular noun for what each entry is, used in the tooltips and the
        ``aria-label`` (e.g. ``"3 of 5 entries"``); pluralized for display
        (``entry`` -> ``entries``). The default is ``'entry'`` rather than
        ``'value'`` because the count includes missing entries, which aren't
        values. `summary` passes ``'row'`` for the whole-frame tally (entries
        are rows).
    class_ : str, default: 'pavement-tally'
        CSS class on the root ``<svg>``, a hook for your own styling.
    path : str, optional
        If given, also write the markup here. A ``.html``/``.htm`` path is
        wrapped in a minimal standalone document; any other suffix is
        written as-is. The string is returned either way.

    Returns
    -------
    str
        The ``<svg>...</svg>`` markup.

    Raises
    ------
    ValueError
        If *data* is empty (no values to summarize).

    See Also
    --------
    spark : The distribution sparkline this strip accompanies.
    pavement.core.tally_stats : The backend-agnostic counts it draws.
    """
    counts = tally_stats(data)
    total = counts['total']
    if total == 0:
        raise ValueError("data must be non-empty")

    horizontal = orientation == 'horizontal'
    view_w, view_h = _VIEWBOX[orientation]
    span = view_w if horizontal else view_h  # axis the boxes lay out along

    stroke = ''
    if line_color is not None:
        stroke = (f' stroke="{line_color}" stroke-width="{_num(line_width)}"'
                  f' vector-effect="non-scaling-stroke"')

    segments = [(label, color, count) for label, color, count in (
        ('distinct', distinct_color, counts['distinct']),
        ('duplicate', repeated_color, counts['repeated']),
        ('missing', missing_color, counts['missing']),
    ) if count > 0]  # a category with no values draws no box
    effective_span = span * max(0.0, min(1.0, fill_ratio))
    lengths = _box_lengths([count for _, _, count in segments], effective_span, min_box)
    word = noun if total == 1 else _plural(noun)

    parts: list[str] = []
    offset = 0.0
    for (label, color, count), length in zip(segments, lengths):
        if horizontal:
            x, y, w, h = offset, 0.0, length, view_h
        else:
            x, y, w, h = 0.0, offset, view_w, length
        title = ''
        if hover:
            text = f"{label}\n{pct(count, total)} ({count:,} of {total:,} {word})"
            if label == 'distinct':  # how many of the distinct values are singletons
                text += f"\n({counts['once']:,} appearing once)"
            title = f'<title>{escape(text)}</title>'
        parts.append(
            f'<rect class="tvbox" x="{_num(x)}" y="{_num(y)}" '
            f'width="{_num(w)}" height="{_num(h)}" fill="{color}"{stroke} '
            f'pointer-events="all">{title}</rect>')
        offset += length

    # CSS hover feedback (pure, scoped): the box under the cursor brightens,
    # signalling that the strip is interactive. The fills are opaque, so a
    # brightness filter reads more clearly here than an opacity change.
    style = ''
    if highlight:
        selector = '.' + '.'.join(class_.split())
        style = (
            f'<style>'
            f'{selector} .tvbox{{transition:filter .1s ease}}'
            f'{selector} .tvbox:hover{{filter:brightness(1.12)}}'
            f'</style>')

    root_style = 'overflow:visible;'  # let the flush outline show its stroke
    if inline:
        root_style += f'height:{height};width:auto;vertical-align:-0.15em;'
    label = (f"column tally: {counts['distinct']} distinct, "
             f"{counts['repeated']} duplicate, {counts['missing']} missing "
             f"of {total} {word}")
    svg = (
        f'<svg xmlns="http://www.w3.org/2000/svg" '
        f'viewBox="0 0 {_num(view_w)} {_num(view_h)}" '
        f'preserveAspectRatio="none" class={quoteattr(class_)} '
        f'role="img" aria-label={quoteattr(label)} '
        f'style={quoteattr(root_style)}>'
        f'<desc>{escape(label)}</desc>{style}'
        f'{"".join(parts)}</svg>')

    if path is not None:
        document = svg
        if path.endswith(('.html', '.htm')):
            document = ('<!doctype html><meta charset="utf-8">'
                        f'<body>{svg}</body>')
        with open(path, 'w', encoding='utf-8') as f:
            f.write(document)
    return svg

summary

summary(
    data: Any,
    color: str = _TALLY_DISTINCT,
    height: str = "1.6em",
    hover: bool = True,
    highlight: bool = True,
    draggable: bool = True,
    min_fill: float = 0.1,
    pebbles: bool = False,
    shared_bounds: bool | None = None,
    narrow_value_cols: bool | None = None,
    labels: Sequence[Any] | None = None,
    class_: str = "pavement-summary",
    path: str | None = None,
) -> Summary

Summarize a dataframe, Series, or sequence as one inline HTML table.

The compact, at-a-glance view to reach for when data first lands. It pairs the column-summary strips of this module into a borderless, headerless table — one row per column, each showing its tally (how much of it is distinct, duplicate, or missing) beside its distribution. The distribution is a pavement spark for an ordered column — numbers, Decimal, or date/datetime (a temporal column is projected onto a time axis, see _project) — and a proportion strip for a categorical one, so every column gets a distribution view where a pavement alone would leave the categorical ones blank. Every box is hoverable for its exact share, value, and count.

The return value renders itself in Jupyter (via _repr_html_), so pavement.summary(df) as the last line of a cell shows the table inline.

What data may be:

  • A dataframe — a pandas or polars DataFrame, or a plain dict mapping column name to a sequence of values (handy with neither installed). Renders one row per column, under a top row summarizing the frame as a whole: its label is the shape ("N by M" — columns by rows), its tally treats each whole row as the entity (so "duplicate" means a duplicated row and "missing" a row that is entirely blank), and its distribution cell is empty (a frame has no single distribution).
  • A ragged dict — a plain dict whose columns are not all the same length (so there is no rectangular "N by M" shape). Renders like a groupby instead: the header shows "N labels" (the column count) and a tally over every value pooled across all columns — the only full-width strip — and each column's tally is scaled to its share of that pooled total (see min_fill). A real DataFrame, always rectangular, never takes this path.
  • A pandas DataFrameGroupBy — e.g. df.groupby("team"). Renders one row per group under a top row showing the group and column counts; each row's tally treats whole rows as the entity (same as the plain DataFrame header), so "duplicate" means a duplicated row within that group. Distribution cells are empty (no single column to show).
  • A pandas SeriesGroupBy — e.g. df["score"].groupby(df["team"]). Renders one row per group, under a top row showing the series name and group count; the top row's tally and distribution cover all values across every group, giving a global view above the per-group breakdown. Multi-key groupby (grouped by several columns) labels each group with its key components joined by /.
  • A Series or 1D sequence — a pandas Series, a list, a numpy array, etc. Renders a single row. A bare sequence has no accessible name, so where a column name would go it shows the entry count instead (e.g. "1,234 entries" — "entries", not "values", since the count includes any missing ones).

A pavement column's resolution adapts to its total value count: a rug (every value shown, each hoverable) up to 24, then equal-mass bins — 4 bins up to 96 values, 8 bins up to 256, then 16 — so a small column reads value-by-value and a large one as a smooth shape.

Parameters:

Name Type Description Default
data DataFrame, DataFrameGroupBy, SeriesGroupBy, dict, Series, or sequence

The thing to summarize (see above).

required
color str

CSS color tinting the numeric distribution sparks, so they match the tally's "distinct" box. The categorical proportion strips keep their own alternating palette.

the tally's dark blue
height str

CSS height of every strip. They share one aspect ratio, so a common height makes the tally and distribution columns line up.

'1.6em'
hover bool

Whether the strips carry their native <title> tooltips.

True
highlight bool

Whether the strips brighten the box under the cursor (scoped CSS).

True
draggable bool

If True, make the column rows drag-and-drop re-orderable, to rearrange them (e.g. to compare columns side by side). A small grip handle appears at the left of each column name and is the only draggable target, so the rest of each row keeps its normal cursor and stays text-selectable. Adds a small, self-contained <script> (the table's only JavaScript) scoped to this one table; the top/total row stays pinned. The script reveals the handles, so where it cannot run — notebooks strip it, static exports have no JS — they stay hidden and the plain static table shows, which is why this is harmless to leave on by default. Pass draggable=False for a guaranteed script-free fragment. Purely visual either way: the new order is not read back into Python. Has no effect unless there are two or more column rows to reorder — a single column, single group, or bare sequence gets no handle (nothing to rearrange).

True
min_fill float

When groups or columns have different row counts (a groupby or a ragged dict with unequal-length values), each tally strip is scaled so its visible width is proportional to its row count. The reference is the total number of entries — pooled across every group, or every column of a ragged dict — so each strip reads on the same scale as the pooled header row, which, covering every entry, is the only full-width strip. min_fill is the floor: even the smallest tally strip uses at least this fraction of the full strip width, so it stays visible. 0 makes the scaling fully proportional (a very small group's strip can shrink to nearly nothing); 1 makes every strip fill its full width (disables the proportional scaling). Has no effect on a rectangular frame, where all columns share one length (every per-column strip is full width).

0.1
pebbles bool

Treat data as a label-to-value mapping and lay it out like a SeriesGroupBy: a header row pooling every value into one full distribution, then one row per label showing just its single value as a lone pebble on the shared axis. The intended input is a labeled 1D result — most often a pandas Series whose index carries the labels, such as df.groupby("team")["score"].mean() (the Series name, if any, becomes the header label); a plain dict of scalars works too. shared_bounds defaults to True under pebbles (so the single values line up on one axis against the pooled header, the point of the view); pass it explicitly to override. Raises ValueError if data is not a per-label scalar input (a DataFrame, dict of columns, GroupBy, or unlabeled sequence has nothing to spread).

False
shared_bounds bool or None

Whether to place all distribution strips on a single shared value axis, so their positions can be compared directly. When True, the global min and max across all groups (or all columns, for a dict) are used as the axis endpoints for every numeric distribution strip; a group whose values occupy only part of the global range shows data in the corresponding portion of the strip, with transparent empty space elsewhere. None (the default) auto-detects: True for groupby inputs (where comparing groups on a common axis is usually the point), False for plain DataFrames and dicts (where columns often have different units or scales). Has no effect on categorical proportion strips. By default it also drives narrow_value_cols (a shared axis usually wants the wider distribution), but that layout is separately controllable — see below.

None
narrow_value_cols bool or None

Whether to halve the two value (extent) columns flanking the distribution and give that width to the distribution column, so the pavements have more room to show where each row's data falls. The overall table width is unchanged. None (the default) follows shared_bounds — the two usually go together — while True or False forces the layout on or off independently (e.g. narrow columns without a shared axis, or keep full-width value columns even with one). Has no visible effect on a groupby of whole rows or a DataFrame header row, whose distribution and extent cells are empty.

None
labels sequence

Which columns (or groups, for a groupby) to show and in what order, overriding the default order from the data. Each entry must match a column name (for a DataFrame or dict) or a group-key string (for a groupby). Raises ValueError if any name is not found. When None (the default), all columns or groups appear in their natural order.

None
class_ str

CSS class on the <table>, a hook for your own styling.

'pavement-summary'
path str

If given, also write the markup here. A .html/.htm path is wrapped in a minimal standalone document; any other suffix is written as-is (the bare <table> fragment).

None

Returns:

Type Description
Summary

An object that renders the table inline in Jupyter and whose str() is the HTML fragment.

See Also

tally : The distinct/duplicate/missing strip in each row. proportion : The categorical distribution strip. spark : The numeric distribution sparkline.

Source code in src/pavement/svg.py
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
def summary(
    data: Any,
    color: str = _TALLY_DISTINCT,
    height: str = '1.6em',
    hover: bool = True,
    highlight: bool = True,
    draggable: bool = True,
    min_fill: float = 0.1,
    pebbles: bool = False,
    shared_bounds: bool | None = None,
    narrow_value_cols: bool | None = None,
    labels: Sequence[Any] | None = None,
    class_: str = 'pavement-summary',
    path: str | None = None,
) -> Summary:
    """
    Summarize a dataframe, Series, or sequence as one inline HTML table.

    The compact, at-a-glance view to reach for when data first lands. It pairs
    the column-summary strips of this module into a borderless, headerless
    table — one row per column, each showing its `tally` (how much of it is
    distinct, duplicate, or missing) beside its distribution. The distribution
    is a pavement `spark` for an ordered column — numbers, ``Decimal``, or
    ``date``/``datetime`` (a temporal column is projected onto a time axis, see
    `_project`) — and a `proportion` strip for a categorical one, so every
    column gets a distribution view where a pavement alone would leave the
    categorical ones blank. Every box is hoverable for its exact share, value,
    and count.

    The return value renders itself in Jupyter (via ``_repr_html_``), so
    ``pavement.summary(df)`` as the last line of a cell shows the table inline.

    What *data* may be:

    - **A dataframe** — a pandas or polars ``DataFrame``, or a plain ``dict``
      mapping column name to a sequence of values (handy with neither
      installed). Renders one row per column, under a top row summarizing the
      frame as a whole: its label is the shape (``"N by M"`` — columns by
      rows), its tally treats each *whole row* as the entity (so "duplicate"
      means a duplicated row and "missing" a row that is entirely blank), and
      its distribution cell is empty (a frame has no single distribution).
    - **A ragged dict** — a plain ``dict`` whose columns are *not* all the same
      length (so there is no rectangular "N by M" shape). Renders like a
      groupby instead: the header shows ``"N labels"`` (the column count) and a
      tally over every value pooled across all columns — the only full-width
      strip — and each column's tally is scaled to its share of that pooled
      total (see *min_fill*). A real ``DataFrame``, always rectangular, never
      takes this path.
    - **A pandas DataFrameGroupBy** — e.g. ``df.groupby("team")``. Renders
      one row per group under a top row showing the group and column counts;
      each row's tally treats whole rows as the entity (same as the plain
      DataFrame header), so "duplicate" means a duplicated row within that
      group. Distribution cells are empty (no single column to show).
    - **A pandas SeriesGroupBy** — e.g. ``df["score"].groupby(df["team"])``.
      Renders one row per group, under a top row showing the series name and
      group count; the top row's tally and distribution cover all values
      across every group, giving a global view above the per-group breakdown.
      Multi-key groupby (grouped by several columns) labels each group with
      its key components joined by `` / ``.
    - **A Series or 1D sequence** — a pandas ``Series``, a list, a numpy
      array, etc. Renders a single row. A bare sequence has no accessible
      name, so where a column name would go it shows the entry count instead
      (e.g. ``"1,234 entries"`` — "entries", not "values", since the count
      includes any missing ones).

    A pavement column's resolution adapts to its total value count: a rug
    (every value shown, each hoverable) up to 24, then equal-mass bins — 4
    bins up to 96 values, 8 bins up to 256, then 16 — so a small column reads
    value-by-value and a large one as a smooth shape.

    Parameters
    ----------
    data : DataFrame, DataFrameGroupBy, SeriesGroupBy, dict, Series, or sequence
        The thing to summarize (see above).
    color : str, default: the tally's dark blue
        CSS color tinting the numeric distribution sparks, so they match the
        tally's "distinct" box. The categorical proportion strips keep their
        own alternating palette.
    height : str, default: '1.6em'
        CSS height of every strip. They share one aspect ratio, so a common
        height makes the tally and distribution columns line up.
    hover : bool, default: True
        Whether the strips carry their native ``<title>`` tooltips.
    highlight : bool, default: True
        Whether the strips brighten the box under the cursor (scoped CSS).
    draggable : bool, default: True
        If True, make the column rows drag-and-drop re-orderable, to rearrange
        them (e.g. to compare columns side by side). A small grip handle appears
        at the left of each column name and is the only draggable target, so the
        rest of each row keeps its normal cursor and stays text-selectable. Adds
        a small, self-contained ``<script>`` (the table's only JavaScript) scoped
        to this one table; the top/total row stays pinned. The script reveals the
        handles, so where it cannot run — notebooks strip it, static exports have
        no JS — they stay hidden and the plain static table shows, which is why
        this is harmless to leave on by default. Pass ``draggable=False`` for a
        guaranteed script-free fragment. Purely visual either way: the new order
        is not read back into Python. Has no effect unless there are two or more
        column rows to reorder — a single column, single group, or bare sequence
        gets no handle (nothing to rearrange).
    min_fill : float, default: 0.1
        When groups or columns have different row counts (a groupby or a
        ragged dict with unequal-length values), each tally strip is scaled so
        its visible width is proportional to its row count. The reference is the
        *total* number of entries — pooled across every group, or every column
        of a ragged dict — so each strip reads on the same scale as the pooled
        header row, which, covering every entry, is the only full-width strip.
        *min_fill* is the floor: even the smallest tally strip uses at least
        this fraction of the full strip width, so it stays visible. ``0`` makes
        the scaling fully proportional (a very small group's strip can shrink to
        nearly nothing); ``1`` makes every strip fill its full width (disables
        the proportional scaling). Has no effect on a rectangular frame, where
        all columns share one length (every per-column strip is full width).
    pebbles : bool, default: False
        Treat *data* as a label-to-value mapping and lay it out like a
        SeriesGroupBy: a header row pooling every value into one full
        distribution, then one row per label showing just its single value as a
        lone pebble on the shared axis. The intended input is a labeled 1D
        result — most often a pandas ``Series`` whose index carries the labels,
        such as ``df.groupby("team")["score"].mean()`` (the Series ``name``, if
        any, becomes the header label); a plain ``dict`` of scalars works too.
        *shared_bounds* defaults to True under *pebbles* (so the single values
        line up on one axis against the pooled header, the point of the view);
        pass it explicitly to override. Raises ``ValueError`` if *data* is not a
        per-label scalar input (a DataFrame, dict of columns, GroupBy, or
        unlabeled sequence has nothing to spread).
    shared_bounds : bool or None, default: None
        Whether to place all distribution strips on a single shared value
        axis, so their positions can be compared directly. When True, the
        global min and max across all groups (or all columns, for a dict)
        are used as the axis endpoints for every numeric distribution strip;
        a group whose values occupy only part of the global range shows data
        in the corresponding portion of the strip, with transparent empty
        space elsewhere. None (the default) auto-detects: True for groupby
        inputs (where comparing groups on a common axis is usually the
        point), False for plain DataFrames and dicts (where columns often
        have different units or scales). Has no effect on categorical
        proportion strips. By default it also drives *narrow_value_cols* (a
        shared axis usually wants the wider distribution), but that layout is
        separately controllable — see below.
    narrow_value_cols : bool or None, default: None
        Whether to halve the two value (extent) columns flanking the
        distribution and give that width to the distribution column, so the
        pavements have more room to show where each row's data falls. The
        overall table width is unchanged. ``None`` (the default) follows
        *shared_bounds* — the two usually go together — while ``True`` or
        ``False`` forces the layout on or off independently (e.g. narrow
        columns without a shared axis, or keep full-width value columns even
        with one). Has no visible effect on a groupby of whole rows or a
        DataFrame header row, whose distribution and extent cells are empty.
    labels : sequence, optional
        Which columns (or groups, for a groupby) to show and in what order,
        overriding the default order from the data. Each entry must match a
        column name (for a DataFrame or dict) or a group-key string (for a
        groupby). Raises ``ValueError`` if any name is not found. When
        ``None`` (the default), all columns or groups appear in their
        natural order.
    class_ : str, default: 'pavement-summary'
        CSS class on the ``<table>``, a hook for your own styling.
    path : str, optional
        If given, also write the markup here. A ``.html``/``.htm`` path is
        wrapped in a minimal standalone document; any other suffix is written
        as-is (the bare ``<table>`` fragment).

    Returns
    -------
    Summary
        An object that renders the table inline in Jupyter and whose ``str()``
        is the HTML fragment.

    See Also
    --------
    tally : The distinct/duplicate/missing strip in each row.
    proportion : The categorical distribution strip.
    spark : The numeric distribution sparkline.
    """
    # pebbles recasts a label->scalar input (e.g. a groupby-mean Series) as a
    # synthetic SeriesGroupBy: a header row pooling every value into one full
    # distribution, then one row per label showing just its single pebble on the
    # shared axis — exactly how a real SeriesGroupBy renders below.
    groupby = _as_pebbles(data) if pebbles else _detect_groupby(data)
    # Resolve shared_bounds early — it shapes the column widths below: None
    # auto-detects (True for a groupby, including pebbles, else False).
    if shared_bounds is None:
        shared_bounds = groupby is not None

    # narrow_value_cols controls the layout (halve the extent columns flanking
    # the distribution, widen the distribution).  None follows shared_bounds —
    # the two usually go together — but either can be set explicitly to mix
    # them: narrow without a shared axis, or a shared axis without narrowing.
    narrow_value_cols = (shared_bounds if narrow_value_cols is None
                         else narrow_value_cols)

    # Compute all column widths from the base height.  All strips keep the same
    # height; tally is 75% as wide as the natural size, distribution 130%.
    # The three text columns get one shared width so the layout is uniform.
    # For non-em heights the widths fall back to width:auto on the SVGs.
    h_em: float | None = None
    # Value-axis viewBox extent for the distribution sparks.  None keeps each
    # spark's default aspect; narrowing sets it to match the widened cell.
    dist_view_w: float | None = None
    if isinstance(height, str) and height.endswith('em'):
        try:
            h_em = float(height[:-2])
        except ValueError:
            pass
    if h_em is not None:
        natural_w = h_em * _SVG_ASPECT
        w_tally_svg = natural_w * _TALLY_WIDTH_SCALE
        w_dist_svg = natural_w * _DIST_WIDTH_SCALE
        w_tally = f'{w_tally_svg:.2f}em'    # for _set_strip_width
        w_dist = f'{w_dist_svg:.2f}em'      # for _set_strip_width
        # <col> widths.  The name column and the two extent columns normally all
        # use _TEXT_COL_CLAMP — the same CSS value guarantees they are equal.
        w_tally_col = f'{w_tally_svg:.2f}em'   # tally cell: no horiz. padding
        w_dist_col = f'{w_dist_svg:.2f}em'    # dist cell: no horiz. padding
        w_ext_col = _TEXT_COL_CLAMP           # extent columns flanking the dist
        if narrow_value_cols:
            # Usually paired with shared bounds: numeric data on one axis, where
            # the extent labels are short and the relative positions across rows
            # are the point.  Halve the two extent columns flanking the
            # distribution and hand the reclaimed width (one full
            # _TEXT_COL_CLAMP) to the distribution column — both the <col> and
            # the SVG — so the pavements get more room.  Table width unchanged.
            w_ext_col = f'calc({_TEXT_COL_CLAMP} / 2)'
            w_dist_col = f'calc({w_dist_svg:.2f}em + {_TEXT_COL_CLAMP})'
            w_dist = w_dist_col
            # The strip now fills a much wider cell, but preserveAspectRatio is
            # "none", so a fixed 140-unit viewBox would stretch x≫y and fatten
            # the vertical strokes.  Widen the spark's viewBox to match the
            # cell's on-screen width:height ratio so the scale stays ~uniform
            # and strokes render crisp.  The vw term is unknowable here, so we
            # match the common desktop case where 20vw is clamped to its max.
            cell_w_em = w_dist_svg + _TEXT_COL_MAX_EM
            dist_view_w = _VIEWBOX['horizontal'][0] * cell_w_em / w_dist_svg
        # Table width = exact sum of column widths, expressed in CSS so the
        # browser never has leftover space to redistribute.  calc() lets us add
        # the responsive clamp() text columns to the fixed-em strip columns.
        w_total_css = (f'calc({_TEXT_COL_CLAMP} + {w_tally_col} + {w_ext_col} '
                       f'+ {w_dist_col} + {w_ext_col})')
        fixed_layout = True
    else:
        w_tally = w_dist = None
        fixed_layout = False

    opts = {'height': height, 'hover': hover, 'highlight': highlight}
    columns_data = None if groupby is not None else _as_columns(data)

    def _global_domain(present: list[Any]) -> tuple[float, float] | None:
        """Projected (lo, hi) axis domain from *present* values, or None."""
        if not present:
            return None
        proj, _ = _project(list(present))
        lo, hi = min(proj), max(proj)
        return (lo - 0.5, hi + 0.5) if lo == hi else (lo, hi)

    rows: list[str] = []
    # Dragging only earns its keep with two or more reorderable rows — a single
    # column, group, or bare sequence has nothing to rearrange, so it gets no
    # handles and no script. Each branch enables it once it knows its row count.
    enable_drag = False

    if groupby is not None and groupby[0] == _GROUPBY_FRAME:
        _, n_cols, group_keys, sub_dfs = groupby
        row_key_lists = []
        for sub_df in sub_dfs:
            col_lists = [list(sub_df[c]) for c in sub_df.columns]
            rks = [_row_key(row) for row in zip(*col_lists)] if col_lists else []
            row_key_lists.append(rks)
        if labels is not None:
            lab_str = [str(lab) for lab in labels]
            missing = [lab for lab in lab_str if lab not in group_keys]
            if missing:
                raise ValueError(
                    f"labels not found in groupby keys: "
                    f"{', '.join(map(repr, missing))}")
            idx = {k: i for i, k in enumerate(group_keys)}
            order = [idx[lab] for lab in lab_str]
            group_keys = [group_keys[i] for i in order]
            row_key_lists = [row_key_lists[i] for i in order]
        n_groups = len(group_keys)
        enable_drag = draggable and n_groups >= 2
        all_row_keys = [rk for rks in row_key_lists for rk in rks]
        shape_label = (f'<span style="{_COUNT_STYLE}">'
                       f'{n_groups:,} {"group" if n_groups == 1 else "groups"}, '
                       f'{n_cols:,} {"column" if n_cols == 1 else "columns"}</span>')
        rows.append(_summary_row(
            shape_label,
            _tally_strip(all_row_keys, 'row', opts, strip_width=w_tally),
            '', '', '', total=True, copy_btn=enable_drag))
        group_sizes = [len(rks) for rks in row_key_lists]
        # Scale each group's tally to its share of *all* rows, so the pooled
        # header (every row) is the only full-width strip and the groups read
        # on the same scale as it.
        total_size = sum(group_sizes)
        for key, row_keys, size in zip(group_keys, row_key_lists, group_sizes):
            fr = max(min_fill, size / total_size) if total_size else 1.0
            rows.append(_summary_row(
                f'<span style="{_NAME_STYLE}">{escape(key)}</span>',
                _tally_strip(row_keys, 'row', opts, strip_width=w_tally,
                             fill_ratio=fr),
                '', '', '', draggable=enable_drag))
    elif groupby is not None:  # _GROUPBY_SERIES
        _, series_name, group_keys, series_groups = groupby
        group_cols = [list(g) for g in series_groups]
        if labels is not None:
            lab_str = [str(lab) for lab in labels]
            missing = [lab for lab in lab_str if lab not in group_keys]
            if missing:
                raise ValueError(
                    f"labels not found in groupby keys: "
                    f"{', '.join(map(repr, missing))}")
            idx = {k: i for i, k in enumerate(group_keys)}
            order = [idx[lab] for lab in lab_str]
            group_keys = [group_keys[i] for i in order]
            group_cols = [group_cols[i] for i in order]
        all_values = [v for col in group_cols for v in col]
        all_present = [v for v in all_values if not _is_missing(v)]
        n_groups = len(group_keys)
        enable_drag = draggable and n_groups >= 2
        # pebbles reuses this branch but its rows are labels, not groups.
        count_part = _count_label(n_groups, 'label' if pebbles else 'group')
        if series_name is not None:
            header_label = (f'<span style="{_NAME_STYLE}">'
                            f'{escape(str(series_name))}</span> {count_part}')
        else:
            header_label = count_part
        global_lo, global_hi = _column_extent(all_values, all_present)
        global_domain = (_global_domain(all_present)
                         if shared_bounds and _pavement_column(all_present)
                         else None)
        rows.append(_summary_row(
            header_label,
            _tally_strip(all_values, 'entry', opts, strip_width=w_tally),
            global_lo,
            _distribution_strip(all_values, all_present, color, opts,
                                strip_width=w_dist, view_width=dist_view_w),
            global_hi, total=True, copy_btn=enable_drag))
        group_sizes = [len(col) for col in group_cols]
        # Scale each group's tally to its share of *all* entries, so the pooled
        # header (every entry) is the only full-width strip and the groups read
        # on the same scale as it. Pebbles rows are single values, so each
        # scales to 1/n of the full width against the header's pooled n.
        total_size = sum(group_sizes)
        for key, values, size in zip(group_keys, group_cols, group_sizes):
            fr = max(min_fill, size / total_size) if total_size else 1.0
            present = [v for v in values if not _is_missing(v)]
            lo, hi = _column_extent(values, present)
            rows.append(_summary_row(
                f'<span style="{_NAME_STYLE}">{escape(key)}</span>',
                _tally_strip(values, 'entry', opts, strip_width=w_tally,
                             fill_ratio=fr),
                lo,
                _distribution_strip(values, present, color, opts,
                                    strip_width=w_dist,
                                    domain=global_domain,
                                    view_width=dist_view_w),
                hi, draggable=enable_drag))
    elif columns_data is not None:
        names, col_values = columns_data
        if labels is not None:
            name_to_idx = {n: i for i, n in enumerate(names)}
            missing = [lab for lab in labels if lab not in name_to_idx]
            if missing:
                raise ValueError(
                    f"labels not found in data: "
                    f"{', '.join(map(repr, missing))}")
            names = list(labels)
            col_values = [col_values[name_to_idx[lab]] for lab in labels]
        n_cols = len(names)
        enable_drag = draggable and n_cols >= 2
        col_sizes = [len(col) for col in col_values]
        all_col_present = [v for col in col_values for v in col if not _is_missing(v)]
        frame_global_domain = (_global_domain(all_col_present)
                               if shared_bounds and _pavement_column(all_col_present)
                               else None)
        # A rectangular frame (a real DataFrame, or a dict whose columns are all
        # the same length) has an "N by M" shape, so its header summarizes whole
        # rows: the shape label, a tally keyed on whole rows, and each column at
        # full width (every column is the same length as the longest).  A ragged
        # dict (columns of differing lengths) has no such rectangle — `zip` would
        # silently truncate the row tally to the shortest column and "N by M"
        # would invent an M — so it renders like a groupby instead: an "N labels"
        # header whose tally pools every value across all columns (the full-width
        # reference) and per-column tallies scaled to their share of that total.
        ragged = len(set(col_sizes)) > 1
        if ragged:
            header_label = _count_label(n_cols, 'label')
            header_tally = _tally_strip(
                [v for col in col_values for v in col], 'entry', opts,
                strip_width=w_tally)
            denom = sum(col_sizes)
        else:
            n_rows = col_sizes[0] if col_sizes else 0
            header_label = (f'<span style="{_COUNT_STYLE}">'
                            f'{n_cols:,} by {n_rows:,}</span>')
            keys = ([_row_key(row) for row in zip(*col_values)]
                    if col_values else [])
            header_tally = _tally_strip(keys, 'row', opts, strip_width=w_tally)
            denom = max(col_sizes, default=1)
        rows.append(_summary_row(
            header_label, header_tally, '', '', '',
            total=True, copy_btn=enable_drag))
        for name, values, size in zip(names, col_values, col_sizes):
            fr = max(min_fill, size / denom) if denom else 1.0
            present = [v for v in values if not _is_missing(v)]
            lo, hi = _column_extent(values, present)
            rows.append(_summary_row(
                f'<span style="{_NAME_STYLE}">{escape(str(name))}</span>',
                _tally_strip(values, 'entry', opts, strip_width=w_tally,
                             fill_ratio=fr),
                lo,
                _distribution_strip(values, present, color, opts,
                                    strip_width=w_dist,
                                    domain=frame_global_domain,
                                    view_width=dist_view_w),
                hi, draggable=enable_drag))
    else:
        if labels is not None:
            raise ValueError(
                "labels selects columns or groups, so it applies to a "
                "DataFrame, dict, or GroupBy input — a single sequence has "
                "no columns to select")
        values = list(data)
        present = [v for v in values if not _is_missing(v)]
        lo, hi = _column_extent(values, present)
        rows.append(_summary_row(
            _count_label(len(values), 'entry'),
            _tally_strip(values, 'entry', opts, strip_width=w_tally),
            lo,
            _distribution_strip(values, present, color, opts,
                                strip_width=w_dist, view_width=dist_view_w),
            hi, draggable=enable_drag))

    if fixed_layout:
        # table-layout:fixed + explicit width ignores cell content entirely.
        # The name and extent <col>s share _TEXT_COL_CLAMP (the extents halved
        # when shared_bounds widens the distribution).  The table width is the
        # exact CSS sum of all column widths via calc(), so the browser never
        # has leftover space to redistribute.
        colgroup = (
            f'<colgroup>'
            f'<col style="width:{_TEXT_COL_CLAMP};"/>'
            f'<col style="width:{w_tally_col};"/>'
            f'<col style="width:{w_ext_col};"/>'
            f'<col style="width:{w_dist_col};"/>'
            f'<col style="width:{w_ext_col};"/>'
            f'</colgroup>'
        )
        table_style = (f'border-collapse:collapse;font-family:inherit;'
                       f'table-layout:fixed;width:{w_total_css};')
    else:
        colgroup = ''
        table_style = 'border-collapse:collapse;font-family:inherit;'

    if enable_drag:
        table_id = f'pavement-summary-{uuid.uuid4().hex[:8]}'
        id_attr = f' id={quoteattr(table_id)}'
        script = _drag_script(table_id)
    else:
        id_attr = script = ''
    table = (
        f'<div style="max-width:{_SUMMARY_MAX_WIDTH};overflow-x:auto;">'
        f'<table{id_attr} class={quoteattr(class_)} style={quoteattr(table_style)}>'
        f'{colgroup}{"".join(rows)}</table>{script}</div>'
    )

    if path is not None:
        document = table
        if path.endswith(('.html', '.htm')):
            document = ('<!doctype html><meta charset="utf-8">'
                        f'<body>{table}</body>')
        with open(path, 'w', encoding='utf-8') as f:
            f.write(document)
    return Summary(table)

Interactive — Plotly (pavement.plotly)

plotly

Interactive pavement plots for Plotly.

The pavement.matplotlib renderer draws static matplotlib artists and the pavement.holoviews module builds backend-agnostic HoloViews elements. This module targets Plotly directly, so it speaks Plotly's own vocabulary — plotly.graph_objects traces, plotly.subplots grids — and slots into a Plotly workflow with native hover, pan, zoom, and legends.

A pavement is a richer drop-in for a rug plot: where a rug draws one tick per data point, a pavement bins the data into equal-mass quantile boxes (or, with bins=None, falls back to a tick per point — a literal rug). The headline use is the same place rugs show up most: as marginals on a scatter, like Plotly's own marginal_x / marginal_y rugs (https://plotly.com/python/marginal-plots/). with_marginals adjoins pavement marginals to a scatter figure in one call.

Each pavement row is built from plain ~plotly.graph_objects.Scatter traces — no figure-level shapes — so a row drops into any subplot cell with row=/col= and carries its own hover. A row is:

  • one borderless filled rectangle per equal-mass bin, each its own trace with hoveron='fills' so hovering anywhere inside the box shows that bin's value range, percentile band, and the share of values inside it;
  • a single line trace for the quantile ticks and box edges (with tassels where a value repeats, so every line is drawn once); and
  • an invisible marker at each quantile tick, carrying that tick's value, percentile, and the share of values on it — the rug-style read.

Plotly hovers filled areas and markers but not lines, so the line trace is purely visual and the two hover layers (box fills, tick markers) carry the text. Hover reads the same as the other backends: the box hover is a value range, percentile band, and value share; the tick hover a single value, percentile, and share (both led by the row's name when it has one). Every value is counted in exactly one bin (strictly inside) or tick (exactly on it).

The functions mirror the rest of the package:

  • pavement_traces builds one row's traces (the low-level piece).
  • plot builds a whole ~plotly.graph_objects.Figure, accepting a single dataset, a wide list of datasets, or tidy data plus categories — the counterpart of pavement.matplotlib.plot and pavement.holoviews.plot.
  • add_pavement adds those rows to an existing figure (optionally into a subplot cell), the building block the other two share.
  • with_marginals builds a scatter-with-marginals joint plot.

Examples:

>>> import pavement.plotly as ppl
>>> ppl.plot([1, 2, 3, 4, 5]).show()
>>> ppl.plot(values, categories=labels).show()

pavement_traces

pavement_traces(
    data: Iterable[float],
    bins: int | None = 4,
    weights: Sequence[float] | None = None,
    position: float = 1,
    width: float = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = None,
    orientation: Literal[
        "vertical", "horizontal"
    ] = "vertical",
    color: str | None = None,
    fill_alpha: float = 0.3,
    line_width: float = 1.0,
    name: str | None = None,
    hover: bool = True,
    value_format: ValueFormat | None = None,
    show_legend: bool = False,
) -> list[Scatter]

Build the Plotly traces for a single pavement row.

The low-level piece the rest of the module is built on: it computes one row's quantile values and returns the ~plotly.graph_objects traces that draw it, ready to add_trace (optionally into a subplot cell). Use plot or add_pavement for the usual single/wide/tidy entry points.

Parameters:

Name Type Description Default
data iterable of float

The values to summarize.

required
bins int or None

Number of equal-mass bins, or None to show every data point (a rug). Passed to pavement.pavement_stats.

4
weights sequence of float

Positive weights parallel to data.

None
position float

Center of the row on the axis perpendicular to the value axis.

1
width float

Thickness of the row.

0.6
tassel_extent float

How far tassel marks extend beyond the box at repeated values.

0.05
show_tassels bool

Whether to draw tassel marks at repeated quantile values.

False
show_box bool or None

Whether to draw the two long box edges. None (the default) draws them when binned and omits them for a rug (bins=None), so a rug reads like a plain rug plot; True or False forces it.

None
orientation ('vertical', 'horizontal')

Direction of the value axis. 'vertical' puts values on the y-axis; 'horizontal' puts them on the x-axis.

'vertical'
color str

Color of the lines and (translucent) fill. Any hex, named, or rgb(...) color Plotly accepts. Defaults to the first Plotly color.

None
fill_alpha float

Opacity of the bin fills. The ticks and box are drawn opaque. Set to 0 to omit the fill entirely.

0.3
line_width float

Width of the tick and box-edge lines.

1.0
name str

Legend/hover name for the row (e.g. its category).

None
hover bool

Whether to enable hover: hoveron='fills' on each bin (so the box hovers anywhere inside) plus an invisible marker at each tick.

True
value_format callable

Function mapping a value to its hover display string, e.g. lambda v: f"${v:,.2f}". Applies to the bin value ranges and tick values; defaults to 3 significant figures.

None
show_legend bool

Whether the row contributes a legend entry (one per row, on its first bin or, if there is no fill, its lines).

False

Returns:

Type Description
list of plotly.graph_objects.Scatter

One trace per bin fill, then the line trace, then (if hover) the tick-marker trace — all sharing a legendgroup so toggling the legend hides the whole row.

See Also

plot : Build a whole figure from one or more datasets. add_pavement : Add rows to an existing figure. pavement.pavement_stats : The underlying quantile computation.

Source code in src/pavement/plotly.py
def pavement_traces(
    data: Iterable[float],
    bins: int | None = 4,
    weights: Sequence[float] | None = None,
    position: float = 1,
    width: float = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = None,
    orientation: Literal["vertical", "horizontal"] = "vertical",
    color: str | None = None,
    fill_alpha: float = 0.3,
    line_width: float = 1.0,
    name: str | None = None,
    hover: bool = True,
    value_format: ValueFormat | None = None,
    show_legend: bool = False,
) -> list[go.Scatter]:
    """
    Build the Plotly traces for a single pavement row.

    The low-level piece the rest of the module is built on: it computes
    one row's quantile values and returns the `~plotly.graph_objects`
    traces that draw it, ready to ``add_trace`` (optionally into a
    subplot cell). Use `plot` or `add_pavement` for the usual
    single/wide/tidy entry points.

    Parameters
    ----------
    data : iterable of float
        The values to summarize.
    bins : int or None, default: 4
        Number of equal-mass bins, or None to show every data point (a
        rug). Passed to `pavement.pavement_stats`.
    weights : sequence of float, optional
        Positive weights parallel to *data*.
    position : float, default: 1
        Center of the row on the axis perpendicular to the value axis.
    width : float, default: 0.6
        Thickness of the row.
    tassel_extent : float, default: 0.05
        How far tassel marks extend beyond the box at repeated values.
    show_tassels : bool, default: False
        Whether to draw tassel marks at repeated quantile values.
    show_box : bool or None, default: None
        Whether to draw the two long box edges. None (the default) draws
        them when binned and omits them for a rug (``bins=None``), so a rug
        reads like a plain rug plot; True or False forces it.
    orientation : {'vertical', 'horizontal'}, default: 'vertical'
        Direction of the value axis. 'vertical' puts values on the
        y-axis; 'horizontal' puts them on the x-axis.
    color : str, optional
        Color of the lines and (translucent) fill. Any hex, named, or
        ``rgb(...)`` color Plotly accepts. Defaults to the first Plotly
        color.
    fill_alpha : float, default: 0.3
        Opacity of the bin fills. The ticks and box are drawn opaque. Set
        to 0 to omit the fill entirely.
    line_width : float, default: 1.0
        Width of the tick and box-edge lines.
    name : str, optional
        Legend/hover name for the row (e.g. its category).
    hover : bool, default: True
        Whether to enable hover: ``hoveron='fills'`` on each bin (so the
        box hovers anywhere inside) plus an invisible marker at each tick.
    value_format : callable, optional
        Function mapping a value to its hover display string, e.g.
        ``lambda v: f"${v:,.2f}"``. Applies to the bin value ranges and
        tick values; defaults to 3 significant figures.
    show_legend : bool, default: False
        Whether the row contributes a legend entry (one per row, on its
        first bin or, if there is no fill, its lines).

    Returns
    -------
    list of plotly.graph_objects.Scatter
        One trace per bin fill, then the line trace, then (if *hover*) the
        tick-marker trace — all sharing a ``legendgroup`` so toggling the
        legend hides the whole row.

    See Also
    --------
    plot : Build a whole figure from one or more datasets.
    add_pavement : Add rows to an existing figure.
    pavement.pavement_stats : The underlying quantile computation.
    """
    data = list(data)
    values = pavement_stats(data, bins=bins, weights=weights)
    geom = _row_geometry(values, position, width, orientation,
                         tassel_extent, show_tassels,
                         show_box, value_format,
                         data=data, rug=bins is None)
    if color is None:
        color = _default_colors(1)[0]
    legendgroup = name if name is not None else None
    # Hover lines: the name (when present), then value / percentile / count —
    # same layout and order as the other backends' hover.
    prefix = [] if name is None else [str(name)]

    traces: list[go.Scatter] = []
    # The first visible trace carries the (single) legend entry; the rest
    # share its legendgroup so a legend click toggles the whole row.
    legend_taken = False

    # Bins: one filled rectangle each, hovering anywhere inside the box.
    # The translucent fill is the row color at fill_alpha opacity: the line
    # is zero-width, so the trace opacity applies to the fill alone — no
    # need to bake an alpha into the color string (and no matplotlib).
    if fill_alpha > 0:
        for xs, ys, band, value_range, count in geom["bins"]:
            # An empty box's band is "" and drops out rather than leaving a
            # blank line — the same handling as a single-value tick's percentile.
            text = "<br>".join(s for s in prefix + [value_range, band, count]
                               if s)
            traces.append(go.Scatter(
                x=xs, y=ys, mode="lines", line=dict(width=0), fill="toself",
                fillcolor=color, opacity=fill_alpha, hoveron="fills",
                text=text if hover else None,
                hovertemplate=_HOVERTEMPLATE if hover else None,
                hoverinfo=None if hover else "skip",
                name=name, legendgroup=legendgroup,
                showlegend=show_legend and not legend_taken))
            legend_taken = True

    # Lines: the quantile ticks and box edges, purely visual (Plotly does
    # not hover lines); the tick markers below carry their hover.
    traces.append(go.Scatter(
        x=geom["line_x"], y=geom["line_y"],
        mode="lines", line=dict(color=color, width=line_width),
        name=name, legendgroup=legendgroup,
        showlegend=show_legend and not legend_taken, hoverinfo="skip"))

    # Tick markers: an invisible point at each quantile value, hovering as
    # a single value, percentile, and share — the rug-style read of a line.
    # An empty percentile (a single-value spark) drops out rather than
    # leaving a blank line.
    if hover:
        texts = ["<br>".join(s for s in prefix + [value, quantile, count] if s)
                 for _, _, quantile, value, count in geom["ticks"]]
        traces.append(go.Scatter(
            x=[t[0] for t in geom["ticks"]],
            y=[t[1] for t in geom["ticks"]],
            mode="markers", marker=dict(color=color, opacity=0),
            text=texts, hovertemplate=_HOVERTEMPLATE,
            name=name, legendgroup=legendgroup, showlegend=False))

    return traces

add_pavement

add_pavement(
    fig: Figure,
    data: Sequence[float] | Sequence[Iterable[float]],
    weights: Sequence[float]
    | Sequence[Sequence[float]]
    | None = None,
    positions: Sequence[float] | None = None,
    categories: Sequence[Hashable] | None = None,
    labels: Sequence[Hashable] | None = None,
    bins: int | None | Sequence[int | None] = 4,
    widths: float | Sequence[float] = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = None,
    orientation: Literal[
        "vertical", "horizontal"
    ] = "vertical",
    color: str | Sequence[str] | None = None,
    fill_alpha: float = 0.3,
    line_width: float = 1.0,
    hover: bool = True,
    value_format: ValueFormat | None = None,
    show_legend: bool = True,
    row: int | None = None,
    col: int | None = None,
) -> Figure

Add one or more pavement rows to an existing figure.

The building block plot and with_marginals share: it accepts the same single/wide/tidy input shapes as pavement.matplotlib.plot, builds the traces for each row, and adds them to fig — into a specific subplot cell when row/col are given. The figure is mutated and returned.

Parameters:

Name Type Description Default
fig Figure

The figure to add to. Mutated in place.

required
data sequence of float, or sequence of iterables of float

The values to plot; shape selects the mode, as in pavement.matplotlib.plot.

required
weights sequence

Positive weights, matching the shape of data.

None
positions sequence of float

Position of each row on the axis perpendicular to the value axis. Defaults to [1, 2, ..., N].

None
categories sequence

Category label per entry in data (tidy/long form). If given, data is split by category.

None
labels sequence

One label per row, used as the legend/hover name. In tidy form, also selects which categories to include and their order.

None
bins int, None, or sequence

Equal-mass bins per row; None shows all the data (a rug). A scalar applies to every row; a sequence sets each and may mix None with integers. See pavement.pavement_stats.

4
widths float or sequence of float

Thickness of each row.

0.6
tassel_extent float

How far tassel marks extend beyond the box.

0.05
show_tassels bool

Whether to draw tassel marks at repeated quantile values.

False
show_box bool or None

Whether to draw each row's two long box edges. None (the default) draws them for a binned row and omits them for a rug (bins=None); True or False forces it. Resolved per row, so a mixed bins sequence gets the right default for each.

None
orientation ('vertical', 'horizontal')

Direction of the value axis.

'vertical'
color str or sequence of str

Per-row color(s). A single color applies to every row; a sequence sets each and must match the number of rows. Defaults to Plotly's qualitative color cycle.

None
fill_alpha float

Opacity of the bin fills (0 omits them).

0.3
line_width float

Width of the tick and box-edge lines.

1.0
hover bool

Whether to add the invisible hover layer to each row.

True
value_format callable

Function mapping a value to its hover display string (e.g. lambda v: f"${v:,.2f}"); defaults to 3 significant figures.

None
show_legend bool

Whether multi-row plots contribute a legend entry per row. A single anonymous row never does.

True
row int

Subplot cell to add the traces to, for a figure built with plotly.subplots.make_subplots. Both or neither.

None
col int

Subplot cell to add the traces to, for a figure built with plotly.subplots.make_subplots. Both or neither.

None

Returns:

Type Description
Figure

fig, with the rows added.

Raises:

Type Description
ValueError

If data is empty; if positions, bins, widths, color, or labels is a sequence of the wrong length; or for any reason raised by pavement.pavement_stats.

See Also

plot : Create a new figure (calls this). pavement_traces : Build a single row's traces.

Source code in src/pavement/plotly.py
def add_pavement(
    fig: go.Figure,
    data: Sequence[float] | Sequence[Iterable[float]],
    weights: Sequence[float] | Sequence[Sequence[float]] | None = None,
    positions: Sequence[float] | None = None,
    categories: Sequence[Hashable] | None = None,
    labels: Sequence[Hashable] | None = None,
    bins: int | None | Sequence[int | None] = 4,
    widths: float | Sequence[float] = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = None,
    orientation: Literal["vertical", "horizontal"] = "vertical",
    color: str | Sequence[str] | None = None,
    fill_alpha: float = 0.3,
    line_width: float = 1.0,
    hover: bool = True,
    value_format: ValueFormat | None = None,
    show_legend: bool = True,
    row: int | None = None,
    col: int | None = None,
) -> go.Figure:
    """
    Add one or more pavement rows to an existing figure.

    The building block `plot` and `with_marginals` share: it accepts
    the same single/wide/tidy input shapes as `pavement.matplotlib.plot`, builds the
    traces for each row, and adds them to *fig* — into a specific subplot
    cell when *row*/*col* are given. The figure is mutated and returned.

    Parameters
    ----------
    fig : plotly.graph_objects.Figure
        The figure to add to. Mutated in place.
    data : sequence of float, or sequence of iterables of float
        The values to plot; shape selects the mode, as in
        `pavement.matplotlib.plot`.
    weights : sequence, optional
        Positive weights, matching the shape of *data*.
    positions : sequence of float, optional
        Position of each row on the axis perpendicular to the value axis.
        Defaults to ``[1, 2, ..., N]``.
    categories : sequence, optional
        Category label per entry in *data* (tidy/long form). If given,
        *data* is split by category.
    labels : sequence, optional
        One label per row, used as the legend/hover name. In tidy form,
        also selects which categories to include and their order.
    bins : int, None, or sequence, default: 4
        Equal-mass bins per row; None shows all the data (a rug). A scalar
        applies to every row; a sequence sets each and may mix None with
        integers. See `pavement.pavement_stats`.
    widths : float or sequence of float, default: 0.6
        Thickness of each row.
    tassel_extent : float, default: 0.05
        How far tassel marks extend beyond the box.
    show_tassels : bool, default: False
        Whether to draw tassel marks at repeated quantile values.
    show_box : bool or None, default: None
        Whether to draw each row's two long box edges. None (the default)
        draws them for a binned row and omits them for a rug
        (``bins=None``); True or False forces it. Resolved per row, so a
        mixed *bins* sequence gets the right default for each.
    orientation : {'vertical', 'horizontal'}, default: 'vertical'
        Direction of the value axis.
    color : str or sequence of str, optional
        Per-row color(s). A single color applies to every row; a sequence
        sets each and must match the number of rows. Defaults to Plotly's
        qualitative color cycle.
    fill_alpha : float, default: 0.3
        Opacity of the bin fills (0 omits them).
    line_width : float, default: 1.0
        Width of the tick and box-edge lines.
    hover : bool, default: True
        Whether to add the invisible hover layer to each row.
    value_format : callable, optional
        Function mapping a value to its hover display string (e.g.
        ``lambda v: f"${v:,.2f}"``); defaults to 3 significant figures.
    show_legend : bool, default: True
        Whether multi-row plots contribute a legend entry per row. A
        single anonymous row never does.
    row, col : int, optional
        Subplot cell to add the traces to, for a figure built with
        `plotly.subplots.make_subplots`. Both or neither.

    Returns
    -------
    plotly.graph_objects.Figure
        *fig*, with the rows added.

    Raises
    ------
    ValueError
        If *data* is empty; if *positions*, *bins*, *widths*, *color*, or
        *labels* is a sequence of the wrong length; or for any reason
        raised by `pavement.pavement_stats`.

    See Also
    --------
    plot : Create a new figure (calls this).
    pavement_traces : Build a single row's traces.
    """
    data, weight_rows, labels, _ = normalize_rows(
        data, weights, categories, labels)
    n = len(data)
    if positions is None:
        positions = list(range(1, n + 1))
    elif len(positions) != n:
        raise ValueError(f"positions has length {len(positions)}, expected {n}")
    bins = broadcast(bins, n, "bins",
                     lambda v: v is None or isinstance(v, Integral))
    widths = broadcast(widths, n, "widths", lambda v: isinstance(v, Number))
    colors = resolve_colors(color, n, _default_colors)

    add = dict(row=row, col=col) if row is not None or col is not None else {}
    for label, dataset, w, pos, b, width, col_ in zip(
            labels, data, weight_rows, positions, bins, widths, colors):
        # An anonymous single row has no legend or hover name; multiple
        # rows are named so they get a legend and named hover.
        name = str(label) if n > 1 else None
        traces = pavement_traces(
            dataset, bins=b, weights=w, position=pos, width=width,
            tassel_extent=tassel_extent, show_tassels=show_tassels,
            show_box=show_box, orientation=orientation, color=col_,
            fill_alpha=fill_alpha, line_width=line_width, name=name,
            hover=hover, value_format=value_format,
            show_legend=show_legend and n > 1)
        for trace in traces:
            fig.add_trace(trace, **add)
    return fig

plot

plot(
    data: Sequence[float] | Sequence[Iterable[float]],
    weights: Sequence[float]
    | Sequence[Sequence[float]]
    | None = None,
    positions: Sequence[float] | None = None,
    categories: Sequence[Hashable] | None = None,
    labels: Sequence[Hashable] | None = None,
    bins: int | None | Sequence[int | None] = 4,
    widths: float | Sequence[float] = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = None,
    orientation: Literal[
        "vertical", "horizontal"
    ] = "vertical",
    value_label: str | None = None,
    value_format: ValueFormat | None = None,
    color: str | Sequence[str] | None = None,
    fill_alpha: float = 0.3,
    line_width: float = 1.0,
    hover: bool = True,
    show_legend: bool = False,
    fig: Figure | None = None,
) -> Figure

Build an interactive pavement plot as a Plotly figure.

The Plotly counterpart of pavement.matplotlib.plot and pavement.holoviews.plot. Accepts the same three input shapes — a single 1D dataset, a wide sequence of datasets, or tidy data plus categories — and returns a ~plotly.graph_objects.Figure with the value axis labelled and the position axis ticked by the row labels.

Parameters:

Name Type Description Default
data sequence of float, or sequence of iterables of float

The values to plot; shape selects the mode, as in pavement.matplotlib.plot.

required
weights sequence

Positive weights, matching the shape of data.

None
positions sequence of float

Position of each row on the axis perpendicular to the value axis. Defaults to [1, 2, ..., N].

None
categories sequence

Category label per entry in data (tidy/long form). If given, data is split by category.

None
labels sequence

One label per row, used as the legend name and position-axis tick. In tidy form, also selects which categories to include and their order.

None
bins int, None, or sequence

Equal-mass bins per row; None shows all the data (a rug). See pavement.pavement_stats.

4
widths float or sequence of float

Thickness of each row.

0.6
tassel_extent float

How far tassel marks extend beyond the box.

0.05
show_tassels bool

Whether to draw tassel marks at repeated quantile values.

False
show_box bool or None

Whether to draw each row's two long box edges. None (the default) draws them for a binned row and omits them for a rug (bins=None), so a rug reads like a plain rug plot; True or False forces it. Resolved per row.

None
orientation ('vertical', 'horizontal')

Direction of the value axis.

'vertical'
value_label str

If given, label the value axis (x for horizontal, y otherwise). Defaults to None (unlabelled), as the matplotlib backend does; pass a string to title the axis.

None
value_format callable

Function mapping a value to its hover display string, e.g. lambda v: f"${v:,.2f}". Applies to the bin value ranges and tick values; defaults to 3 significant figures.

None
color str or sequence of str

Per-row color(s). Defaults to Plotly's qualitative color cycle, so a category-split pavement matches a default Plotly Express scatter group for group.

None
fill_alpha float

Opacity of the bin fills (0 omits them).

0.3
line_width float

Width of the tick and box-edge lines.

1.0
hover bool

Whether to enable hover (via the invisible marker layer).

True
show_legend bool

Whether to show the legend (only relevant with multiple rows). Off by default; pass True to label the rows with a legend.

False
fig Figure

Figure to draw into. Defaults to a fresh one. (Passing a subplot figure here draws into its default cell; for a specific cell, use add_pavement with row/col.)

None

Returns:

Type Description
Figure

A figure containing the pavement, with axes labelled and ticked.

Raises:

Type Description
ValueError

If data is empty; if positions, bins, widths, color, or labels is a sequence of the wrong length; or for any reason raised by pavement.pavement_stats.

See Also

pavement.matplotlib.plot : The matplotlib equivalent. pavement.holoviews.plot : The HoloViews equivalent. with_marginals : Adjoin pavement marginals to a scatter. add_pavement : The lower-level adder this wraps.

Examples:

>>> import pavement.plotly as ppl
>>> ppl.plot([1, 2, 3, 4, 5]).show()
>>> ppl.plot(values, categories=labels).show()
Source code in src/pavement/plotly.py
def plot(
    data: Sequence[float] | Sequence[Iterable[float]],
    weights: Sequence[float] | Sequence[Sequence[float]] | None = None,
    positions: Sequence[float] | None = None,
    categories: Sequence[Hashable] | None = None,
    labels: Sequence[Hashable] | None = None,
    bins: int | None | Sequence[int | None] = 4,
    widths: float | Sequence[float] = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = None,
    orientation: Literal["vertical", "horizontal"] = "vertical",
    value_label: str | None = None,
    value_format: ValueFormat | None = None,
    color: str | Sequence[str] | None = None,
    fill_alpha: float = 0.3,
    line_width: float = 1.0,
    hover: bool = True,
    show_legend: bool = False,
    fig: go.Figure | None = None,
) -> go.Figure:
    """
    Build an interactive pavement plot as a Plotly figure.

    The Plotly counterpart of `pavement.matplotlib.plot` and
    `pavement.holoviews.plot`. Accepts the same three input shapes — a
    single 1D dataset, a wide sequence of datasets, or tidy data plus
    *categories* — and returns a `~plotly.graph_objects.Figure` with the
    value axis labelled and the position axis ticked by the row labels.

    Parameters
    ----------
    data : sequence of float, or sequence of iterables of float
        The values to plot; shape selects the mode, as in
        `pavement.matplotlib.plot`.
    weights : sequence, optional
        Positive weights, matching the shape of *data*.
    positions : sequence of float, optional
        Position of each row on the axis perpendicular to the value axis.
        Defaults to ``[1, 2, ..., N]``.
    categories : sequence, optional
        Category label per entry in *data* (tidy/long form). If given,
        *data* is split by category.
    labels : sequence, optional
        One label per row, used as the legend name and position-axis tick.
        In tidy form, also selects which categories to include and their
        order.
    bins : int, None, or sequence, default: 4
        Equal-mass bins per row; None shows all the data (a rug). See
        `pavement.pavement_stats`.
    widths : float or sequence of float, default: 0.6
        Thickness of each row.
    tassel_extent : float, default: 0.05
        How far tassel marks extend beyond the box.
    show_tassels : bool, default: False
        Whether to draw tassel marks at repeated quantile values.
    show_box : bool or None, default: None
        Whether to draw each row's two long box edges. None (the default)
        draws them for a binned row and omits them for a rug
        (``bins=None``), so a rug reads like a plain rug plot; True or
        False forces it. Resolved per row.
    orientation : {'vertical', 'horizontal'}, default: 'vertical'
        Direction of the value axis.
    value_label : str, optional
        If given, label the value axis (x for horizontal, y otherwise).
        Defaults to ``None`` (unlabelled), as the ``matplotlib`` backend
        does; pass a string to title the axis.
    value_format : callable, optional
        Function mapping a value to its hover display string, e.g.
        ``lambda v: f"${v:,.2f}"``. Applies to the bin value ranges and
        tick values; defaults to 3 significant figures.
    color : str or sequence of str, optional
        Per-row color(s). Defaults to Plotly's qualitative color cycle, so
        a category-split pavement matches a default Plotly Express scatter
        group for group.
    fill_alpha : float, default: 0.3
        Opacity of the bin fills (0 omits them).
    line_width : float, default: 1.0
        Width of the tick and box-edge lines.
    hover : bool, default: True
        Whether to enable hover (via the invisible marker layer).
    show_legend : bool, default: False
        Whether to show the legend (only relevant with multiple rows).
        Off by default; pass True to label the rows with a legend.
    fig : plotly.graph_objects.Figure, optional
        Figure to draw into. Defaults to a fresh one. (Passing a subplot
        figure here draws into its default cell; for a specific cell, use
        `add_pavement` with *row*/*col*.)

    Returns
    -------
    plotly.graph_objects.Figure
        A figure containing the pavement, with axes labelled and ticked.

    Raises
    ------
    ValueError
        If *data* is empty; if *positions*, *bins*, *widths*, *color*, or
        *labels* is a sequence of the wrong length; or for any reason
        raised by `pavement.pavement_stats`.

    See Also
    --------
    pavement.matplotlib.plot : The matplotlib equivalent.
    pavement.holoviews.plot : The HoloViews equivalent.
    with_marginals : Adjoin pavement marginals to a scatter.
    add_pavement : The lower-level adder this wraps.

    Examples
    --------
    >>> import pavement.plotly as ppl
    >>> ppl.plot([1, 2, 3, 4, 5]).show()                # doctest: +SKIP
    >>> ppl.plot(values, categories=labels).show()      # doctest: +SKIP
    """
    if fig is None:
        fig = go.Figure()
    # Resolve labels/positions once here so the axes match the rows added.
    rows, _, resolved_labels, labelled = normalize_rows(
        data, weights, categories, labels)
    n = len(rows)
    if positions is None:
        positions = list(range(1, n + 1))
    max_width = max(broadcast(
        widths, n, "widths", lambda v: isinstance(v, Number)))

    add_pavement(
        fig, data, weights=weights, positions=positions,
        categories=categories, labels=labels, bins=bins, widths=widths,
        tassel_extent=tassel_extent, show_tassels=show_tassels,
        show_box=show_box, orientation=orientation, color=color,
        fill_alpha=fill_alpha, line_width=line_width, hover=hover,
        value_format=value_format, show_legend=show_legend)

    pos_kw = _position_axis_kwargs(labelled, positions, resolved_labels,
                                   max_width)
    if orientation == "horizontal":
        fig.update_xaxes(title_text=value_label)
        fig.update_yaxes(**pos_kw)
    else:
        fig.update_yaxes(title_text=value_label)
        fig.update_xaxes(**pos_kw)
    # 'closest' resolves hover to the nearest box or tick, rather than
    # stacking every trace sharing an axis coordinate.
    fig.update_layout(showlegend=show_legend and n > 1, hovermode="closest")
    return fig

with_marginals

with_marginals(
    main: Figure,
    x: Sequence[float] | None = None,
    y: Sequence[float] | None = None,
    categories: Sequence[Hashable] | None = None,
    size: float = 0.15,
    spacing: float = 0.02,
    **kwargs: Any,
) -> Figure

Adjoin pavement marginals to a scatter figure — x on top, y on right.

A pavement-flavored take on Plotly's marginal_x / marginal_y rugs (https://plotly.com/python/marginal-plots/): pass the figure you would otherwise show and the marginal data, and get back a joint plot with the scatter in the main cell and a thin pavement strip on the top (for x) and the right (for y). The marginals share the scatter's data axes, so they stay aligned through pan and zoom.

The main figure's traces are moved into a fresh subplot grid, so main should be a finished scatter (any styling on its traces is preserved). With categories, each marginal is split by category and colored to match the scatter group for group — colors are read off main's traces by name, so a Plotly Express colored scatter and its marginals share one scheme for free.

Parameters:

Name Type Description Default
main Figure

The central scatter figure. Its traces are re-added to the joint plot's main cell.

required
x sequence of float

Data for the top (x) and right (y) marginals. Provide either or both; at least one is required. For a category split these are the per-point values in tidy form, parallel to categories.

None
y sequence of float

Data for the top (x) and right (y) marginals. Provide either or both; at least one is required. For a category split these are the per-point values in tidy form, parallel to categories.

None
categories sequence

Category label per point, parallel to x and y. Splits each marginal by category, as in plot.

None
size float

Thickness of each marginal strip, as a fraction of the figure.

0.15
spacing float

Gap between the marginal strips and the main cell, as a fraction of the figure.

0.02
**kwargs Any

Forwarded to add_pavement for both marginals (e.g. bins, fill_alpha, show_tassels, tassel_extent, line_width, value_format). orientation, color, and show_legend are managed here.

{}

Returns:

Type Description
Figure

A new figure: the scatter with the requested marginals adjoined.

Raises:

Type Description
ValueError

If neither x nor y is given, or if orientation, color, or show_legend is passed in kwargs (they are managed here).

See Also

plot : Builds the marginal rows; call it for a standalone plot.

Examples:

>>> import plotly.express as px
>>> import pavement.plotly as ppl
>>> df = px.data.iris()
>>> fig = px.scatter(df, x="sepal_width", y="sepal_length",
...                  color="species")
>>> ppl.with_marginals(fig, x=df.sepal_width, y=df.sepal_length,
...                    categories=df.species).show()
Source code in src/pavement/plotly.py
def with_marginals(
    main: go.Figure,
    x: Sequence[float] | None = None,
    y: Sequence[float] | None = None,
    categories: Sequence[Hashable] | None = None,
    size: float = 0.15,
    spacing: float = 0.02,
    **kwargs: Any,
) -> go.Figure:
    """
    Adjoin pavement marginals to a scatter figure — x on top, y on right.

    A pavement-flavored take on Plotly's ``marginal_x`` / ``marginal_y``
    rugs (https://plotly.com/python/marginal-plots/): pass the figure you
    would otherwise show and the marginal data, and get back a joint plot
    with the scatter in the main cell and a thin pavement strip on the top
    (for x) and the right (for y). The marginals share the scatter's data
    axes, so they stay aligned through pan and zoom.

    The *main* figure's traces are moved into a fresh subplot grid, so
    *main* should be a finished scatter (any styling on its traces is
    preserved). With *categories*, each marginal is split by category and
    colored to match the scatter group for group — colors are read off
    *main*'s traces by name, so a Plotly Express colored scatter and its
    marginals share one scheme for free.

    Parameters
    ----------
    main : plotly.graph_objects.Figure
        The central scatter figure. Its traces are re-added to the joint
        plot's main cell.
    x, y : sequence of float, optional
        Data for the top (x) and right (y) marginals. Provide either or
        both; at least one is required. For a category split these are the
        per-point values in tidy form, parallel to *categories*.
    categories : sequence, optional
        Category label per point, parallel to *x* and *y*. Splits each
        marginal by category, as in `plot`.
    size : float, default: 0.15
        Thickness of each marginal strip, as a fraction of the figure.
    spacing : float, default: 0.02
        Gap between the marginal strips and the main cell, as a fraction
        of the figure.
    **kwargs
        Forwarded to `add_pavement` for both marginals (e.g. *bins*,
        *fill_alpha*, *show_tassels*, *tassel_extent*, *line_width*,
        *value_format*). *orientation*, *color*, and *show_legend* are
        managed here.

    Returns
    -------
    plotly.graph_objects.Figure
        A new figure: the scatter with the requested marginals adjoined.

    Raises
    ------
    ValueError
        If neither *x* nor *y* is given, or if *orientation*, *color*, or
        *show_legend* is passed in *kwargs* (they are managed here).

    See Also
    --------
    plot : Builds the marginal rows; call it for a standalone plot.

    Examples
    --------
    >>> import plotly.express as px
    >>> import pavement.plotly as ppl
    >>> df = px.data.iris()                                 # doctest: +SKIP
    >>> fig = px.scatter(df, x="sepal_width", y="sepal_length",
    ...                  color="species")                   # doctest: +SKIP
    >>> ppl.with_marginals(fig, x=df.sepal_width, y=df.sepal_length,
    ...                    categories=df.species).show()    # doctest: +SKIP
    """
    if x is None and y is None:
        raise ValueError("provide x and/or y data for the marginals")
    for managed in ("orientation", "color", "show_legend"):
        if managed in kwargs:
            raise ValueError(
                f"{managed} is managed by with_marginals; call add_pavement "
                "directly if you need to set it")

    # Categories that color the marginals: match the scatter group for
    # group when split, else let the marginals use their own default.
    if categories is not None:
        labels = sorted(set(categories))
        cmap = _color_map(main, labels)
        colors = [cmap[label] for label in labels]
    else:
        colors = None

    main_row, main_col = 2, 1
    fig = make_subplots(
        rows=2, cols=2,
        column_widths=[1 - size, size], row_heights=[size, 1 - size],
        horizontal_spacing=spacing, vertical_spacing=spacing,
        shared_xaxes=True, shared_yaxes=True)

    for trace in main.data:
        fig.add_trace(trace, row=main_row, col=main_col)

    marg = dict(categories=categories, color=colors, show_legend=False,
                **kwargs)
    if x is not None:
        add_pavement(fig, x, orientation="horizontal", row=1, col=1, **marg)
    if y is not None:
        add_pavement(fig, y, orientation="vertical", row=2, col=2, **marg)

    # Carry over the scatter's axis titles to the main cell's axes, and
    # hide the marginal strips' perpendicular axes so they read as strips.
    x_title = main.layout.xaxis.title.text
    y_title = main.layout.yaxis.title.text
    fig.update_xaxes(title_text=x_title, row=main_row, col=main_col)
    fig.update_yaxes(title_text=y_title, row=main_row, col=main_col)
    fig.update_yaxes(showticklabels=False, row=1, col=1)  # x-marginal
    fig.update_xaxes(showticklabels=False, row=2, col=2)  # y-marginal
    fig.update_layout(
        showlegend=any(getattr(t, "showlegend", None) for t in main.data)
        or main.layout.showlegend is True,
        hovermode="closest")
    return fig

Interactive — Bokeh (pavement.bokeh)

bokeh

Interactive pavement plots for Bokeh.

The matplotlib renderer in the top-level package draws static artists, the pavement.holoviews module builds backend-agnostic HoloViews elements, and pavement.plotly targets Plotly directly. This module targets Bokeh in the same spirit: it speaks Bokeh's own vocabulary — bokeh.plotting.figure, glyphs backed by ~bokeh.models.ColumnDataSource, a ~bokeh.models.HoverTool and an interactive ~bokeh.models.Legend — and slots into a Bokeh workflow with native hover, pan, zoom, and a clickable legend.

A pavement is a richer drop-in for a rug plot: where a rug draws one tick per data point, a pavement bins the data into equal-mass quantile boxes (or, with bins=None, falls back to a tick per point — a literal rug). The headline use is the same place rugs show up most: as marginals on a scatter. with_marginals arranges a scatter with pavement marginals on the top and right, with their ranges linked to the scatter's, in one call.

Each pavement row is drawn with plain Bokeh glyphs, so it carries its own hover and drops onto any figure:

  • one borderless filled quad <bokeh.plotting.figure.quad> per equal-mass bin, hovering its value range, percentile band, and the share of values inside it;
  • a segment <bokeh.plotting.figure.segment> of quantile ticks (reaching past the box into a tassel where a value repeats, so every line is drawn once), each hovering its value, percentile, and the share of values on it — the rug-style read; and
  • a segment <bokeh.plotting.figure.segment> of the two box edges, purely visual, sharing the ticks' style.

Hover reads the same as the other backends: the box hover is a value range, percentile band, and value share; the tick hover a single value, percentile, and share (both led by the row's name when it has one). Every value is counted in exactly one bin (strictly inside) or tick (exactly on it). Unlike Plotly's figure-level shapes, Bokeh glyphs hover directly, so no invisible marker layer is needed.

The functions mirror the rest of the package:

  • pavement_glyphs adds one row's glyphs to a figure (the low-level piece).
  • add_pavement adds one or more rows — accepting a single dataset, a wide list of datasets, or tidy data plus categories — and wires up the shared hover and legend.
  • plot builds a whole ~bokeh.plotting.figure, the counterpart of pavement.matplotlib.plot, pavement.holoviews.plot, and pavement.plotly.plot.
  • with_marginals builds a scatter-with-marginals joint plot.

Examples:

>>> import pavement.bokeh as pbk
>>> from bokeh.plotting import show
>>> show(pbk.plot([1, 2, 3, 4, 5]))
>>> show(pbk.plot(values, categories=labels))

pavement_glyphs

pavement_glyphs(
    fig: figure,
    data: Iterable[float],
    bins: int | None = 4,
    weights: Sequence[float] | None = None,
    position: float = 1,
    width: float = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = None,
    orientation: Literal[
        "vertical", "horizontal"
    ] = "vertical",
    color: str | None = None,
    fill_alpha: float = 0.3,
    line_width: float = 1.0,
    name: Hashable | None = None,
    value_format: ValueFormat | None = None,
) -> dict[str, GlyphRenderer]

Add a single pavement row's glyphs to a Bokeh figure.

The low-level piece the rest of the module is built on: it computes one row's quantile values and draws them onto fig as Bokeh glyphs, returning the renderers. It draws only the glyphs — the shared hover tool and legend are figure-level concerns wired up by add_pavement, so reach for plot or add_pavement for the usual single/wide/tidy entry points and interactivity.

Parameters:

Name Type Description Default
fig figure

The figure to draw on. Mutated in place.

required
data iterable of float

The values to summarize.

required
bins int or None

Number of equal-mass bins, or None to show every data point (a rug). Passed to pavement.pavement_stats.

4
weights sequence of float

Positive weights parallel to data.

None
position float

Center of the row on the axis perpendicular to the value axis.

1
width float

Thickness of the row.

0.6
tassel_extent float

How far tassel marks extend beyond the box at repeated values.

0.05
show_tassels bool

Whether to draw tassel marks at repeated quantile values.

False
show_box bool or None

Whether to draw the two long box edges. None (the default) draws them when binned and omits them for a rug (bins=None), so a rug reads like a plain rug plot; True or False forces it.

None
orientation ('vertical', 'horizontal')

Direction of the value axis. 'vertical' puts values on the y-axis; 'horizontal' puts them on the x-axis.

'vertical'
color str

Color of the lines and (translucent) fill. Defaults to the first Bokeh Category10 color.

None
fill_alpha float

Opacity of the bin fills. The ticks and box are drawn opaque. Set to 0 to omit the fills entirely.

0.3
line_width float

Width of the tick and box-edge lines.

1.0
name hashable

Row name (e.g. its category). Carried as a group column on the hover sources so it can lead the hover text, and set as each renderer's name.

None
value_format callable

Function mapping a value to its hover display string, e.g. lambda v: f"${v:,.2f}". Applies to the bin value ranges and tick values; defaults to 3 significant figures.

None

Returns:

Type Description
dict

Maps component name to the ~bokeh.models.GlyphRenderer added:

  • "fills": the bin quads (a hover target), or None if fill_alpha is 0.
  • "ticks": the quantile-tick segments (a hover target).
  • "box": the two box-edge segments (purely visual), or None when show_box resolves to False (e.g. a rug).
See Also

add_pavement : Add one or more rows and wire up hover and the legend. plot : Build a whole figure from one or more datasets. pavement.pavement_stats : The underlying quantile computation.

Source code in src/pavement/bokeh.py
def pavement_glyphs(
    fig: figure,
    data: Iterable[float],
    bins: int | None = 4,
    weights: Sequence[float] | None = None,
    position: float = 1,
    width: float = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = None,
    orientation: Literal["vertical", "horizontal"] = "vertical",
    color: str | None = None,
    fill_alpha: float = 0.3,
    line_width: float = 1.0,
    name: Hashable | None = None,
    value_format: ValueFormat | None = None,
) -> dict[str, GlyphRenderer]:
    """
    Add a single pavement row's glyphs to a Bokeh figure.

    The low-level piece the rest of the module is built on: it computes one
    row's quantile values and draws them onto *fig* as Bokeh glyphs,
    returning the renderers. It draws only the glyphs — the shared hover
    tool and legend are figure-level concerns wired up by `add_pavement`, so
    reach for `plot` or `add_pavement` for the usual single/wide/tidy
    entry points and interactivity.

    Parameters
    ----------
    fig : bokeh.plotting.figure
        The figure to draw on. Mutated in place.
    data : iterable of float
        The values to summarize.
    bins : int or None, default: 4
        Number of equal-mass bins, or None to show every data point (a rug).
        Passed to `pavement.pavement_stats`.
    weights : sequence of float, optional
        Positive weights parallel to *data*.
    position : float, default: 1
        Center of the row on the axis perpendicular to the value axis.
    width : float, default: 0.6
        Thickness of the row.
    tassel_extent : float, default: 0.05
        How far tassel marks extend beyond the box at repeated values.
    show_tassels : bool, default: False
        Whether to draw tassel marks at repeated quantile values.
    show_box : bool or None, default: None
        Whether to draw the two long box edges. None (the default) draws
        them when binned and omits them for a rug (``bins=None``), so a rug
        reads like a plain rug plot; True or False forces it.
    orientation : {'vertical', 'horizontal'}, default: 'vertical'
        Direction of the value axis. 'vertical' puts values on the y-axis;
        'horizontal' puts them on the x-axis.
    color : str, optional
        Color of the lines and (translucent) fill. Defaults to the first
        Bokeh ``Category10`` color.
    fill_alpha : float, default: 0.3
        Opacity of the bin fills. The ticks and box are drawn opaque. Set to
        0 to omit the fills entirely.
    line_width : float, default: 1.0
        Width of the tick and box-edge lines.
    name : hashable, optional
        Row name (e.g. its category). Carried as a ``group`` column on the
        hover sources so it can lead the hover text, and set as each
        renderer's ``name``.
    value_format : callable, optional
        Function mapping a value to its hover display string, e.g.
        ``lambda v: f"${v:,.2f}"``. Applies to the bin value ranges and
        tick values; defaults to 3 significant figures.

    Returns
    -------
    dict
        Maps component name to the `~bokeh.models.GlyphRenderer` added:

        - ``"fills"``: the bin quads (a hover target), or ``None`` if
          *fill_alpha* is 0.
        - ``"ticks"``: the quantile-tick segments (a hover target).
        - ``"box"``: the two box-edge segments (purely visual), or
          ``None`` when *show_box* resolves to False (e.g. a rug).

    See Also
    --------
    add_pavement : Add one or more rows and wire up hover and the legend.
    plot : Build a whole figure from one or more datasets.
    pavement.pavement_stats : The underlying quantile computation.
    """
    data = list(data)
    values = pavement_stats(data, bins=bins, weights=weights)
    geom = _row_geometry(values, position, width, orientation,
                         tassel_extent, show_tassels,
                         show_box, value_format,
                         data=data, rug=bins is None)
    if color is None:
        color = _default_colors(1)[0]
    name_str = None if name is None else str(name)

    renderers: dict[str, GlyphRenderer] = {"fills": None, "box": None}

    # Each bin and tick carries a single ``hover`` string — the row name (when
    # present), value, percentile, and count, joined by line breaks with the
    # empty lines dropped (an empty box has no band; a single-value tick no
    # percentile). One composed field, rather than a fixed multi-field
    # template, is what lets those empties fall out cleanly. See `hover_html`.

    # Bins: one borderless filled quad each, hovering anywhere inside. A rug
    # over a single distinct value has no bins at all (its only "bins" are
    # the zero-width ones `hover_bins` drops), so there is nothing to fill.
    if fill_alpha > 0 and geom["bins"]:
        left, right, bottom, top, band, value_range, counts = zip(*geom["bins"])
        hover = [hover_html(name_str, v, q, c)
                 for v, q, c in zip(value_range, band, counts)]
        fill_data = dict(left=left, right=right, bottom=bottom, top=top,
                         hover=hover)
        renderers["fills"] = fig.quad(
            left="left", right="right", bottom="bottom", top="top",
            source=ColumnDataSource(fill_data),
            fill_color=color, fill_alpha=fill_alpha, line_color=None,
            name=name_str)

    # Ticks: a segment per distinct quantile value, hovering its single
    # quantile and value. Drawn opaque, like the box.
    x0, y0, x1, y1, quantile, value, counts = zip(*geom["ticks"])
    hover = [hover_html(name_str, v, q, c)
             for v, q, c in zip(value, quantile, counts)]
    tick_data = dict(x0=x0, y0=y0, x1=x1, y1=y1, hover=hover)
    renderers["ticks"] = fig.segment(
        x0="x0", y0="y0", x1="x1", y1="y1",
        source=ColumnDataSource(tick_data),
        line_color=color, line_width=line_width,
        name=name_str)

    # Box edges: the two long sides, purely visual (no hover), same style.
    # Absent for a rug (show_box False), so only the ticks remain.
    if geom["box"]:
        bx0, by0, bx1, by1 = zip(*geom["box"])
        renderers["box"] = fig.segment(
            x0=list(bx0), y0=list(by0), x1=list(bx1), y1=list(by1),
            line_color=color, line_width=line_width)

    return renderers

add_pavement

add_pavement(
    fig: figure,
    data: Sequence[float] | Sequence[Iterable[float]],
    weights: Sequence[float]
    | Sequence[Sequence[float]]
    | None = None,
    positions: Sequence[float] | None = None,
    categories: Sequence[Hashable] | None = None,
    labels: Sequence[Hashable] | None = None,
    bins: int | None | Sequence[int | None] = 4,
    widths: float | Sequence[float] = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = None,
    orientation: Literal[
        "vertical", "horizontal"
    ] = "vertical",
    color: str | Sequence[str] | None = None,
    fill_alpha: float = 0.3,
    line_width: float = 1.0,
    hover: bool = True,
    value_format: ValueFormat | None = None,
    show_legend: bool = True,
) -> figure

Add one or more pavement rows to an existing figure.

The building block plot and with_marginals share: it accepts the same single/wide/tidy input shapes as pavement.matplotlib.plot, draws each row's glyphs, and wires up the shared interactivity — one ~bokeh.models.HoverTool over all the rows' bins and ticks, and, for multiple rows, a clickable ~bokeh.models.Legend (each entry toggles its whole row). The figure is mutated and returned.

Parameters:

Name Type Description Default
fig figure

The figure to add to. Mutated in place.

required
data sequence of float, or sequence of iterables of float

The values to plot; shape selects the mode, as in pavement.matplotlib.plot.

required
weights sequence

Positive weights, matching the shape of data.

None
positions sequence of float

Position of each row on the axis perpendicular to the value axis. Defaults to [1, 2, ..., N].

None
categories sequence

Category label per entry in data (tidy/long form). If given, data is split by category.

None
labels sequence

One label per row, used as the legend/hover name. In tidy form, also selects which categories to include and their order.

None
bins int, None, or sequence

Equal-mass bins per row; None shows all the data (a rug). A scalar applies to every row; a sequence sets each and may mix None with integers. See pavement.pavement_stats.

4
widths float or sequence of float

Thickness of each row.

0.6
tassel_extent float

How far tassel marks extend beyond the box.

0.05
show_tassels bool

Whether to draw tassel marks at repeated quantile values.

False
show_box bool or None

Whether to draw each row's two long box edges. None (the default) draws them for a binned row and omits them for a rug (bins=None); True or False forces it. Resolved per row, so a mixed bins sequence gets the right default for each.

None
orientation ('vertical', 'horizontal')

Direction of the value axis.

'vertical'
color str or sequence of str

Per-row color(s). A single color applies to every row; a sequence sets each and must match the number of rows. Defaults to Bokeh's Category10 palette.

None
fill_alpha float

Opacity of the bin fills (0 omits them).

0.3
line_width float

Width of the tick and box-edge lines.

1.0
hover bool

Whether to add a hover tool over the rows' bins and ticks.

True
value_format callable

Function mapping a value to its hover display string (e.g. lambda v: f"${v:,.2f}"); defaults to 3 significant figures.

None
show_legend bool

Whether multi-row plots get a legend (one clickable entry per row). A single anonymous row never does.

True

Returns:

Type Description
figure

fig, with the rows added.

Raises:

Type Description
ValueError

If data is empty; if positions, bins, widths, color, or labels is a sequence of the wrong length; or for any reason raised by pavement.pavement_stats.

See Also

plot : Create a new figure (calls this). pavement_glyphs : Draw a single row's glyphs.

Source code in src/pavement/bokeh.py
def add_pavement(
    fig: figure,
    data: Sequence[float] | Sequence[Iterable[float]],
    weights: Sequence[float] | Sequence[Sequence[float]] | None = None,
    positions: Sequence[float] | None = None,
    categories: Sequence[Hashable] | None = None,
    labels: Sequence[Hashable] | None = None,
    bins: int | None | Sequence[int | None] = 4,
    widths: float | Sequence[float] = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = None,
    orientation: Literal["vertical", "horizontal"] = "vertical",
    color: str | Sequence[str] | None = None,
    fill_alpha: float = 0.3,
    line_width: float = 1.0,
    hover: bool = True,
    value_format: ValueFormat | None = None,
    show_legend: bool = True,
) -> figure:
    """
    Add one or more pavement rows to an existing figure.

    The building block `plot` and `with_marginals` share: it accepts the
    same single/wide/tidy input shapes as `pavement.matplotlib.plot`, draws each row's
    glyphs, and wires up the shared interactivity — one `~bokeh.models.HoverTool`
    over all the rows' bins and ticks, and, for multiple rows, a clickable
    `~bokeh.models.Legend` (each entry toggles its whole row). The figure is
    mutated and returned.

    Parameters
    ----------
    fig : bokeh.plotting.figure
        The figure to add to. Mutated in place.
    data : sequence of float, or sequence of iterables of float
        The values to plot; shape selects the mode, as in `pavement.matplotlib.plot`.
    weights : sequence, optional
        Positive weights, matching the shape of *data*.
    positions : sequence of float, optional
        Position of each row on the axis perpendicular to the value axis.
        Defaults to ``[1, 2, ..., N]``.
    categories : sequence, optional
        Category label per entry in *data* (tidy/long form). If given,
        *data* is split by category.
    labels : sequence, optional
        One label per row, used as the legend/hover name. In tidy form, also
        selects which categories to include and their order.
    bins : int, None, or sequence, default: 4
        Equal-mass bins per row; None shows all the data (a rug). A scalar
        applies to every row; a sequence sets each and may mix None with
        integers. See `pavement.pavement_stats`.
    widths : float or sequence of float, default: 0.6
        Thickness of each row.
    tassel_extent : float, default: 0.05
        How far tassel marks extend beyond the box.
    show_tassels : bool, default: False
        Whether to draw tassel marks at repeated quantile values.
    show_box : bool or None, default: None
        Whether to draw each row's two long box edges. None (the default)
        draws them for a binned row and omits them for a rug
        (``bins=None``); True or False forces it. Resolved per row, so a
        mixed *bins* sequence gets the right default for each.
    orientation : {'vertical', 'horizontal'}, default: 'vertical'
        Direction of the value axis.
    color : str or sequence of str, optional
        Per-row color(s). A single color applies to every row; a sequence
        sets each and must match the number of rows. Defaults to Bokeh's
        ``Category10`` palette.
    fill_alpha : float, default: 0.3
        Opacity of the bin fills (0 omits them).
    line_width : float, default: 1.0
        Width of the tick and box-edge lines.
    hover : bool, default: True
        Whether to add a hover tool over the rows' bins and ticks.
    value_format : callable, optional
        Function mapping a value to its hover display string (e.g.
        ``lambda v: f"${v:,.2f}"``); defaults to 3 significant figures.
    show_legend : bool, default: True
        Whether multi-row plots get a legend (one clickable entry per row). A
        single anonymous row never does.

    Returns
    -------
    bokeh.plotting.figure
        *fig*, with the rows added.

    Raises
    ------
    ValueError
        If *data* is empty; if *positions*, *bins*, *widths*, *color*, or
        *labels* is a sequence of the wrong length; or for any reason raised
        by `pavement.pavement_stats`.

    See Also
    --------
    plot : Create a new figure (calls this).
    pavement_glyphs : Draw a single row's glyphs.
    """
    data, weight_rows, labels, _ = normalize_rows(
        data, weights, categories, labels)
    n = len(data)
    if positions is None:
        positions = list(range(1, n + 1))
    elif len(positions) != n:
        raise ValueError(f"positions has length {len(positions)}, expected {n}")
    bins = broadcast(bins, n, "bins",
                     lambda v: v is None or isinstance(v, Integral))
    widths = broadcast(widths, n, "widths", lambda v: isinstance(v, Number))
    colors = resolve_colors(color, n, _default_colors)

    fill_renderers: list[GlyphRenderer] = []
    tick_renderers: list[GlyphRenderer] = []
    legend_items: list[LegendItem] = []
    for label, dataset, w, pos, b, width, col in zip(
            labels, data, weight_rows, positions, bins, widths, colors):
        # An anonymous single row has no legend or hover name; multiple rows
        # are named so they get a legend and named hover.
        name = label if n > 1 else None
        rends = pavement_glyphs(
            fig, dataset, bins=b, weights=w, position=pos, width=width,
            tassel_extent=tassel_extent, show_tassels=show_tassels,
            show_box=show_box, orientation=orientation, color=col,
            fill_alpha=fill_alpha, line_width=line_width, name=name,
            value_format=value_format)
        if rends["fills"] is not None:
            fill_renderers.append(rends["fills"])
        tick_renderers.append(rends["ticks"])
        if n > 1:
            # One legend entry per row, toggling its whole row (fill, ticks,
            # box) — the box hides too, so a click clears the row entirely.
            row_renderers = [rends[r] for r in ("fills", "ticks", "box")
                             if rends[r] is not None]
            legend_items.append(
                LegendItem(label=str(label), renderers=row_renderers))

    if hover:
        # One hover tool over every bin and tick. Both carry a single ``hover``
        # column holding the already-composed tooltip text (name, value,
        # percentile, count — empties dropped), rendered as raw HTML so its line
        # breaks show: a hovered bin reads as its value range, percentile band,
        # and the share of values inside it; a hovered tick as its value,
        # percentile, and the share on it — led by the name when present.
        fig.add_tools(HoverTool(
            renderers=fill_renderers + tick_renderers,
            tooltips="@hover{safe}"))

    if legend_items and show_legend:
        legend = Legend(items=legend_items, click_policy="hide")
        fig.add_layout(legend)

    return fig

plot

plot(
    data: Sequence[float] | Sequence[Iterable[float]],
    weights: Sequence[float]
    | Sequence[Sequence[float]]
    | None = None,
    positions: Sequence[float] | None = None,
    categories: Sequence[Hashable] | None = None,
    labels: Sequence[Hashable] | None = None,
    bins: int | None | Sequence[int | None] = 4,
    widths: float | Sequence[float] = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = None,
    orientation: Literal[
        "vertical", "horizontal"
    ] = "vertical",
    value_label: str | None = None,
    value_format: ValueFormat | None = None,
    color: str | Sequence[str] | None = None,
    fill_alpha: float = 0.3,
    line_width: float = 1.0,
    hover: bool = True,
    show_legend: bool = False,
    fig: figure | None = None,
    **figure_kwargs: Any,
) -> figure

Build an interactive pavement plot as a Bokeh figure.

The Bokeh counterpart of pavement.matplotlib.plot, pavement.holoviews.plot, and pavement.plotly.plot. Accepts the same three input shapes — a single 1D dataset, a wide sequence of datasets, or tidy data plus categories — and returns a ~bokeh.plotting.figure with the value axis labelled and the position axis ticked by the row labels.

Parameters:

Name Type Description Default
data sequence of float, or sequence of iterables of float

The values to plot; shape selects the mode, as in pavement.matplotlib.plot.

required
weights sequence

Positive weights, matching the shape of data.

None
positions sequence of float

Position of each row on the axis perpendicular to the value axis. Defaults to [1, 2, ..., N].

None
categories sequence

Category label per entry in data (tidy/long form). If given, data is split by category.

None
labels sequence

One label per row, used as the legend name and position-axis tick. In tidy form, also selects which categories to include and their order.

None
bins int, None, or sequence

Equal-mass bins per row; None shows all the data (a rug). See pavement.pavement_stats.

4
widths float or sequence of float

Thickness of each row.

0.6
tassel_extent float

How far tassel marks extend beyond the box.

0.05
show_tassels bool

Whether to draw tassel marks at repeated quantile values.

False
show_box bool or None

Whether to draw each row's two long box edges. None (the default) draws them for a binned row and omits them for a rug (bins=None), so a rug reads like a plain rug plot; True or False forces it. Resolved per row.

None
orientation ('vertical', 'horizontal')

Direction of the value axis.

'vertical'
value_label str

If given, label the value axis (x for horizontal, y otherwise). Defaults to None (unlabelled), as the matplotlib backend does; pass a string to label the axis.

None
value_format callable

Function mapping a value to its hover display string, e.g. lambda v: f"${v:,.2f}". Applies to the bin value ranges and tick values; defaults to 3 significant figures.

None
color str or sequence of str

Per-row color(s). Defaults to Bokeh's Category10 palette, so a category-split pavement matches a scatter colored from the same palette group for group.

None
fill_alpha float

Opacity of the bin fills (0 omits them).

0.3
line_width float

Width of the tick and box-edge lines.

1.0
hover bool

Whether to enable hover.

True
show_legend bool

Whether to show the legend (only relevant with multiple rows). Off by default; pass True to label the rows with a legend.

False
fig figure

Figure to draw into. Defaults to a fresh one built from figure_kwargs.

None
**figure_kwargs Any

Forwarded to bokeh.plotting.figure when fig is None (e.g. width, height, title).

{}

Returns:

Type Description
figure

A figure containing the pavement, with axes labelled and ticked.

Raises:

Type Description
ValueError

If data is empty; if positions, bins, widths, color, or labels is a sequence of the wrong length; or for any reason raised by pavement.pavement_stats.

See Also

pavement.matplotlib.plot : The matplotlib equivalent. pavement.plotly.plot : The Plotly equivalent. with_marginals : Arrange a scatter with pavement marginals. add_pavement : The lower-level adder this wraps.

Examples:

>>> import pavement.bokeh as pbk
>>> from bokeh.plotting import show
>>> show(pbk.plot([1, 2, 3, 4, 5]))
>>> show(pbk.plot(values, categories=labels))
Source code in src/pavement/bokeh.py
def plot(
    data: Sequence[float] | Sequence[Iterable[float]],
    weights: Sequence[float] | Sequence[Sequence[float]] | None = None,
    positions: Sequence[float] | None = None,
    categories: Sequence[Hashable] | None = None,
    labels: Sequence[Hashable] | None = None,
    bins: int | None | Sequence[int | None] = 4,
    widths: float | Sequence[float] = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = None,
    orientation: Literal["vertical", "horizontal"] = "vertical",
    value_label: str | None = None,
    value_format: ValueFormat | None = None,
    color: str | Sequence[str] | None = None,
    fill_alpha: float = 0.3,
    line_width: float = 1.0,
    hover: bool = True,
    show_legend: bool = False,
    fig: figure | None = None,
    **figure_kwargs: Any,
) -> figure:
    """
    Build an interactive pavement plot as a Bokeh figure.

    The Bokeh counterpart of `pavement.matplotlib.plot`, `pavement.holoviews.plot`,
    and `pavement.plotly.plot`. Accepts the same three input shapes — a
    single 1D dataset, a wide sequence of datasets, or tidy data plus
    *categories* — and returns a `~bokeh.plotting.figure` with the value axis
    labelled and the position axis ticked by the row labels.

    Parameters
    ----------
    data : sequence of float, or sequence of iterables of float
        The values to plot; shape selects the mode, as in `pavement.matplotlib.plot`.
    weights : sequence, optional
        Positive weights, matching the shape of *data*.
    positions : sequence of float, optional
        Position of each row on the axis perpendicular to the value axis.
        Defaults to ``[1, 2, ..., N]``.
    categories : sequence, optional
        Category label per entry in *data* (tidy/long form). If given, *data*
        is split by category.
    labels : sequence, optional
        One label per row, used as the legend name and position-axis tick. In
        tidy form, also selects which categories to include and their order.
    bins : int, None, or sequence, default: 4
        Equal-mass bins per row; None shows all the data (a rug). See
        `pavement.pavement_stats`.
    widths : float or sequence of float, default: 0.6
        Thickness of each row.
    tassel_extent : float, default: 0.05
        How far tassel marks extend beyond the box.
    show_tassels : bool, default: False
        Whether to draw tassel marks at repeated quantile values.
    show_box : bool or None, default: None
        Whether to draw each row's two long box edges. None (the default)
        draws them for a binned row and omits them for a rug
        (``bins=None``), so a rug reads like a plain rug plot; True or
        False forces it. Resolved per row.
    orientation : {'vertical', 'horizontal'}, default: 'vertical'
        Direction of the value axis.
    value_label : str, optional
        If given, label the value axis (x for horizontal, y otherwise).
        Defaults to ``None`` (unlabelled), as the ``matplotlib`` backend
        does; pass a string to label the axis.
    value_format : callable, optional
        Function mapping a value to its hover display string, e.g.
        ``lambda v: f"${v:,.2f}"``. Applies to the bin value ranges and
        tick values; defaults to 3 significant figures.
    color : str or sequence of str, optional
        Per-row color(s). Defaults to Bokeh's ``Category10`` palette, so a
        category-split pavement matches a scatter colored from the same
        palette group for group.
    fill_alpha : float, default: 0.3
        Opacity of the bin fills (0 omits them).
    line_width : float, default: 1.0
        Width of the tick and box-edge lines.
    hover : bool, default: True
        Whether to enable hover.
    show_legend : bool, default: False
        Whether to show the legend (only relevant with multiple rows).
        Off by default; pass True to label the rows with a legend.
    fig : bokeh.plotting.figure, optional
        Figure to draw into. Defaults to a fresh one built from
        *figure_kwargs*.
    **figure_kwargs
        Forwarded to `bokeh.plotting.figure` when *fig* is None (e.g.
        *width*, *height*, *title*).

    Returns
    -------
    bokeh.plotting.figure
        A figure containing the pavement, with axes labelled and ticked.

    Raises
    ------
    ValueError
        If *data* is empty; if *positions*, *bins*, *widths*, *color*, or
        *labels* is a sequence of the wrong length; or for any reason raised
        by `pavement.pavement_stats`.

    See Also
    --------
    pavement.matplotlib.plot : The matplotlib equivalent.
    pavement.plotly.plot : The Plotly equivalent.
    with_marginals : Arrange a scatter with pavement marginals.
    add_pavement : The lower-level adder this wraps.

    Examples
    --------
    >>> import pavement.bokeh as pbk
    >>> from bokeh.plotting import show
    >>> show(pbk.plot([1, 2, 3, 4, 5]))                 # doctest: +SKIP
    >>> show(pbk.plot(values, categories=labels))       # doctest: +SKIP
    """
    if fig is None:
        fig = figure(**figure_kwargs)
    # Resolve labels/positions once here so the axes match the rows added.
    rows, _, resolved_labels, labelled = normalize_rows(
        data, weights, categories, labels)
    n = len(rows)
    if positions is None:
        positions = list(range(1, n + 1))
    max_width = max(broadcast(
        widths, n, "widths", lambda v: isinstance(v, Number)))

    add_pavement(
        fig, data, weights=weights, positions=positions,
        categories=categories, labels=labels, bins=bins, widths=widths,
        tassel_extent=tassel_extent, show_tassels=show_tassels,
        show_box=show_box, orientation=orientation, color=color,
        fill_alpha=fill_alpha, line_width=line_width, hover=hover,
        value_format=value_format, show_legend=show_legend)

    # Label the value axis (x for horizontal, y otherwise); tick/pad the
    # perpendicular position axis.
    if orientation == "horizontal":
        fig.xaxis.axis_label = value_label
    else:
        fig.yaxis.axis_label = value_label
    _setup_position_axis(fig, orientation, labelled, positions,
                         resolved_labels, max_width)
    return fig

with_marginals

with_marginals(
    main: figure,
    x: Sequence[float] | None = None,
    y: Sequence[float] | None = None,
    categories: Sequence[Hashable] | None = None,
    size: int = 120,
    **kwargs: Any,
) -> Any

Arrange a scatter with pavement marginals — x on top, y on the right.

A pavement-flavored take on a joint plot: pass the scatter figure you would otherwise show and the marginal data, and get back a ~bokeh.models.GridPlot with the scatter in the main cell and a thin pavement strip on the top (for x) and the right (for y). The marginals' ranges are linked to the scatter's, so they stay aligned through pan and zoom.

With categories, each marginal is split by category and colored to match the scatter group for group — colors are read off main's renderers by name (so set name= on the scatter's per-category renderers), and any label the scatter doesn't color falls back to Bokeh's default palette.

Parameters:

Name Type Description Default
main figure

The central scatter figure. Reused as the main cell; its ranges are shared with the marginals.

required
x sequence of float

Data for the top (x) and right (y) marginals. Provide either or both; at least one is required. For a category split these are the per-point values in tidy form, parallel to categories.

None
y sequence of float

Data for the top (x) and right (y) marginals. Provide either or both; at least one is required. For a category split these are the per-point values in tidy form, parallel to categories.

None
categories sequence

Category label per point, parallel to x and y. Splits each marginal by category, as in plot.

None
size int

Thickness of each marginal strip in pixels.

120
**kwargs Any

Forwarded to add_pavement for both marginals (e.g. bins, fill_alpha, show_tassels, tassel_extent, line_width, value_format). orientation, color, and show_legend are managed here.

{}

Returns:

Type Description
GridPlot

A grid laying out the scatter with the requested marginals adjoined.

Raises:

Type Description
ValueError

If neither x nor y is given, or if orientation, color, or show_legend is passed in kwargs (they are managed here).

See Also

plot : Builds the marginal rows; call it for a standalone plot.

Examples:

>>> import pavement.bokeh as pbk
>>> from bokeh.plotting import figure, show
>>> p = figure()
>>> p.scatter(xs, ys)
>>> show(pbk.with_marginals(p, x=xs, y=ys))
Source code in src/pavement/bokeh.py
def with_marginals(
    main: figure,
    x: Sequence[float] | None = None,
    y: Sequence[float] | None = None,
    categories: Sequence[Hashable] | None = None,
    size: int = 120,
    **kwargs: Any,
) -> Any:
    """
    Arrange a scatter with pavement marginals — x on top, y on the right.

    A pavement-flavored take on a joint plot: pass the scatter figure you
    would otherwise show and the marginal data, and get back a
    `~bokeh.models.GridPlot` with the scatter in the main cell and a thin
    pavement strip on the top (for x) and the right (for y). The marginals'
    ranges are linked to the scatter's, so they stay aligned through pan and
    zoom.

    With *categories*, each marginal is split by category and colored to
    match the scatter group for group — colors are read off *main*'s
    renderers by name (so set ``name=`` on the scatter's per-category
    renderers), and any label the scatter doesn't color falls back to
    Bokeh's default palette.

    Parameters
    ----------
    main : bokeh.plotting.figure
        The central scatter figure. Reused as the main cell; its ranges are
        shared with the marginals.
    x, y : sequence of float, optional
        Data for the top (x) and right (y) marginals. Provide either or both;
        at least one is required. For a category split these are the
        per-point values in tidy form, parallel to *categories*.
    categories : sequence, optional
        Category label per point, parallel to *x* and *y*. Splits each
        marginal by category, as in `plot`.
    size : int, default: 120
        Thickness of each marginal strip in pixels.
    **kwargs
        Forwarded to `add_pavement` for both marginals (e.g. *bins*,
        *fill_alpha*, *show_tassels*, *tassel_extent*, *line_width*,
        *value_format*). *orientation*, *color*, and *show_legend* are
        managed here.

    Returns
    -------
    bokeh.models.GridPlot
        A grid laying out the scatter with the requested marginals adjoined.

    Raises
    ------
    ValueError
        If neither *x* nor *y* is given, or if *orientation*, *color*, or
        *show_legend* is passed in *kwargs* (they are managed here).

    See Also
    --------
    plot : Builds the marginal rows; call it for a standalone plot.

    Examples
    --------
    >>> import pavement.bokeh as pbk
    >>> from bokeh.plotting import figure, show
    >>> p = figure()                                        # doctest: +SKIP
    >>> p.scatter(xs, ys)                                   # doctest: +SKIP
    >>> show(pbk.with_marginals(p, x=xs, y=ys))             # doctest: +SKIP
    """
    if x is None and y is None:
        raise ValueError("provide x and/or y data for the marginals")
    for managed in ("orientation", "color", "show_legend"):
        if managed in kwargs:
            raise ValueError(
                f"{managed} is managed by with_marginals; call add_pavement "
                "directly if you need to set it")

    # Categories that color the marginals: match the scatter group for group
    # when split, else let the marginals use their own default.
    if categories is not None:
        labels = sorted(set(categories))
        cmap = _color_map(main, labels)
        colors = [cmap[label] for label in labels]
    else:
        colors = None

    main_w = main.width or 600
    main_h = main.height or 600
    main.width, main.height = main_w, main_h
    marg = dict(categories=categories, color=colors, show_legend=False,
                **kwargs)

    top = right = None
    if x is not None:
        # The top strip's value axis (x) is shared with the scatter; its
        # position axis (y) is a meaningless thin strip, so hide it.
        top = figure(width=main_w, height=size, x_range=main.x_range,
                     tools="", toolbar_location=None)
        add_pavement(top, x, orientation="horizontal", **marg)
        top.yaxis.visible = False
        top.xaxis.visible = False
    if y is not None:
        right = figure(width=size, height=main_h, y_range=main.y_range,
                       tools="", toolbar_location=None)
        add_pavement(right, y, orientation="vertical", **marg)
        right.xaxis.visible = False
        right.yaxis.visible = False

    # `<<`-style layout: x on top spanning the scatter, y on the right.
    # Skip the top row entirely when there is no x-marginal, so a y-only
    # joint plot doesn't carry an empty strip above the scatter.
    grid = []
    if top is not None:
        grid.append([top, None])
    grid.append([main, right])
    return gridplot(grid, toolbar_location="right", merge_tools=True)

Interactive — HoloViews (pavement.holoviews)

holoviews

Interactive pavement plots for HoloViews.

The matplotlib renderer in the top-level package draws static artists. This module builds the same pavement geometry as HoloViews elements, so it renders through any HoloViews backend — bokeh and plotly for interactivity (hover, pan, zoom, legends), or matplotlib for a static image. Backend-specific styling (fill colors, the hover tool) is resolved for whichever backend is active when the plot is built, so select it with hv.extension(...) first, as usual.

A pavement row is built from three overlaid components: a borderless holoviews.Rectangles of the equal-mass bins (a hover target carrying each bin's value range, percentile band, and value share), and two holoviews.Segments — the quantile ticks and the box edges. Keeping the lines separate from the fill means the ticks and box share one consistent style; a repeated quantile value (data piled up) simply extends its own tick into a tassel, so every line is drawn exactly once. The ticks carry their own hover, like a rug plot's.

The headline function is plot, which mirrors the matplotlib backend's pavement.matplotlib.plot: it accepts a single dataset, a wide list of datasets, or tidy data plus categories, and returns a HoloViews object. Because the result is a plain HoloViews element, framework features compose on top of it — overlay it on a scatter, adjoin it as a marginal with the << operator, or split it by category into a colored, legended NdOverlay. To adjoin pavements as joint-plot marginals, reach for with_marginals, which places an x-marginal on top and a y-marginal on the right with the correct orientation handled for you.

Examples:

>>> import holoviews as hv
>>> import pavement.holoviews as phv
>>> hv.extension('bokeh')
>>> phv.plot([1, 2, 3, 4, 5])
>>> phv.plot(values, categories=labels)

pavement_elements

pavement_elements(
    data: Iterable[float],
    bins: int | None = 4,
    weights: Sequence[float] | None = None,
    position: float = 1,
    width: float = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = None,
    orientation: Literal[
        "vertical", "horizontal"
    ] = "vertical",
    group: Hashable | None = None,
    value_format: ValueFormat | None = None,
) -> dict[str, Any]

Build the raw HoloViews elements for a single pavement row.

The lower-level companion to plot: it computes one row's quantile values and returns the unstyled component elements, leaving styling, overlaying, and axis labelling to the caller. plot wraps this.

Parameters:

Name Type Description Default
data iterable of float

The values to summarize.

required
bins int or None

Number of equal-mass bins, or None to show every data point (a rug). Passed to pavement.pavement_stats.

4
weights sequence of float

Positive weights parallel to data.

None
position float

Center of the row on the axis perpendicular to the value axis.

1
width float

Thickness of the row.

0.6
tassel_extent float

How far tassel marks extend beyond the box at repeated values.

0.05
show_tassels bool

Whether to draw tassel marks at repeated quantile values.

False
show_box bool or None

Whether to draw the two long box edges. None (the default) draws them when binned and omits them for a rug (bins=None), so a rug reads like a plain rug plot; True or False forces it. When omitted, the "box" element is an empty holoviews.Segments.

None
orientation ('vertical', 'horizontal')

Direction of the value axis. 'vertical' puts values on the y-axis; 'horizontal' puts them on the x-axis.

'vertical'
group hashable

If given, the row name; it leads each fill's and tick's composed hover string.

None
value_format callable

Function mapping a value to its hover display string, e.g. lambda v: f"${v:,.2f}". Applies to the bin value ranges and tick values; defaults to 3 significant figures.

None

Returns:

Type Description
dict

Maps component name to the unstyled HoloViews element:

  • "fill": a holoviews.Rectangles of the equal-mass bins, with value dimensions low, high, and hover (the composed tooltip string). Meant to be drawn borderless, as a hover target behind the lines.
  • "ticks": a holoviews.Segments, one tick per distinct quantile value (extended into a tassel where the value repeats), with value dimension hover.
  • "box": a holoviews.Segments of the two long box edges.
See Also

plot : The headline, multi-row function built on this. pavement.pavement_stats : The underlying quantile computation.

Source code in src/pavement/holoviews.py
def pavement_elements(
    data: Iterable[float],
    bins: int | None = 4,
    weights: Sequence[float] | None = None,
    position: float = 1,
    width: float = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = None,
    orientation: Literal["vertical", "horizontal"] = "vertical",
    group: Hashable | None = None,
    value_format: ValueFormat | None = None,
) -> dict[str, Any]:
    """
    Build the raw HoloViews elements for a single pavement row.

    The lower-level companion to `plot`: it computes one row's quantile
    values and returns the unstyled component elements, leaving styling,
    overlaying, and axis labelling to the caller. `plot` wraps this.

    Parameters
    ----------
    data : iterable of float
        The values to summarize.
    bins : int or None, default: 4
        Number of equal-mass bins, or None to show every data point (a
        rug). Passed to `pavement.pavement_stats`.
    weights : sequence of float, optional
        Positive weights parallel to *data*.
    position : float, default: 1
        Center of the row on the axis perpendicular to the value axis.
    width : float, default: 0.6
        Thickness of the row.
    tassel_extent : float, default: 0.05
        How far tassel marks extend beyond the box at repeated values.
    show_tassels : bool, default: False
        Whether to draw tassel marks at repeated quantile values.
    show_box : bool or None, default: None
        Whether to draw the two long box edges. None (the default) draws
        them when binned and omits them for a rug (``bins=None``), so a rug
        reads like a plain rug plot; True or False forces it. When omitted,
        the ``"box"`` element is an empty `holoviews.Segments`.
    orientation : {'vertical', 'horizontal'}, default: 'vertical'
        Direction of the value axis. 'vertical' puts values on the
        y-axis; 'horizontal' puts them on the x-axis.
    group : hashable, optional
        If given, the row name; it leads each fill's and tick's composed
        ``hover`` string.
    value_format : callable, optional
        Function mapping a value to its hover display string, e.g.
        ``lambda v: f"${v:,.2f}"``. Applies to the bin value ranges and
        tick values; defaults to 3 significant figures.

    Returns
    -------
    dict
        Maps component name to the unstyled HoloViews element:

        - ``"fill"``: a `holoviews.Rectangles` of the equal-mass bins,
          with value dimensions ``low``, ``high``, and ``hover`` (the
          composed tooltip string). Meant to be drawn borderless, as a
          hover target behind the lines.
        - ``"ticks"``: a `holoviews.Segments`, one tick per distinct
          quantile value (extended into a tassel where the value
          repeats), with value dimension ``hover``.
        - ``"box"``: a `holoviews.Segments` of the two long box edges.

    See Also
    --------
    plot : The headline, multi-row function built on this.
    pavement.pavement_stats : The underlying quantile computation.
    """
    data = list(data)
    values = pavement_stats(data, bins=bins, weights=weights)
    fills, ticks, edges = _row_geometry(
        values, position, width, orientation,
        tassel_extent, show_tassels, show_box,
        group, value_format, data=data, rug=bins is None)
    return {
        "fill": hv.Rectangles(fills, vdims=_FILL_VDIMS),
        "ticks": hv.Segments(ticks, vdims=_TICK_VDIMS),
        "box": hv.Segments(edges),
    }

plot

plot(
    data: Sequence[float] | Sequence[Iterable[float]],
    weights: Sequence[float]
    | Sequence[Sequence[float]]
    | None = None,
    positions: Sequence[float] | None = None,
    categories: Sequence[Hashable] | None = None,
    labels: Sequence[Hashable] | None = None,
    bins: int | None | Sequence[int | None] = 4,
    widths: float | Sequence[float] = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = None,
    orientation: Literal[
        "vertical", "horizontal"
    ] = "vertical",
    value_label: str | None = None,
    value_format: ValueFormat | None = None,
    color: str | Sequence[str] | None = None,
    fill_alpha: float = 0.3,
    hover: bool = True,
    show_legend: bool = False,
    transpose_labels: bool = False,
) -> Any

Build an interactive pavement plot as a HoloViews object.

The HoloViews counterpart of pavement.matplotlib.plot. Accepts the same three input shapes — a single 1D dataset, a wide sequence of datasets, or tidy data plus categories — and returns a HoloViews object that renders through any backend.

A single dataset returns a holoviews.Overlay (the bins, plus any tassels). Multiple rows return a holoviews.NdOverlay keyed by labels, which gives a legend and a consistent per-row color cycle; in tidy form this is the "split by category" case. Either result is a plain HoloViews object, so it composes with the framework: overlay it with *, adjoin it as a marginal with <<, or restyle it with .opts.

Parameters:

Name Type Description Default
data sequence of float, or sequence of iterables of float

The values to plot. Shape determines the mode, as in pavement.matplotlib.plot.

required
weights sequence

Positive weights, matching the shape of data.

None
positions sequence of float

Position of each row on the axis perpendicular to the value axis. Defaults to [1, 2, ..., N].

None
categories sequence

Category label per entry in data (tidy/long form). If given, data is split by category.

None
labels sequence

One label per row, used as the legend key and color order. In tidy form, also selects which categories to include and their order. Defaults to [1, 2, ..., N] (wide) or the sorted categories (tidy).

None
bins int, None, or sequence

Equal-mass bins per row; None shows all the data (a rug). A scalar applies to every row; a sequence sets each row and may mix None with integers. See pavement.pavement_stats.

4
widths float or sequence of float

Thickness of each row.

0.6
tassel_extent float

How far tassel marks extend beyond the box.

0.05
show_tassels bool

Whether to draw tassel marks at repeated quantile values.

False
show_box bool or None

Whether to draw each row's two long box edges. None (the default) draws them for a binned row and omits them for a rug (bins=None), so a rug reads like a plain rug plot; True or False forces it. Resolved per row, so a mixed bins sequence gets the right default for each.

None
orientation ('vertical', 'horizontal')

Direction of the value axis.

'vertical'
value_label str

If given, label the value axis (x for horizontal, y otherwise). Defaults to None (unlabelled), as the matplotlib backend does; pass a string to label the axis.

None
value_format callable

Function mapping a value to its hover display string, e.g. lambda v: f"${v:,.2f}". Applies to the bin value ranges and tick values; defaults to 3 significant figures.

None
color str or sequence of str

Per-row color(s). A single color applies to every row; a sequence sets each row and must match the number of rows. Defaults to HoloViews' own color cycle, so a category-split pavement's groups match a default-colored main plot's groups (in the same key order) when used as a marginal.

None
fill_alpha float

Opacity of the bin fills. Bin borders (the ticks and box) are drawn opaque.

0.3
hover bool

Whether to enable a hover tool (bokeh only; plotly hovers by default, matplotlib has none).

True
show_legend bool

Whether to show the category legend (only relevant with multiple rows). Off by default; pass True to label the rows with a legend. Has no effect on matplotlib, which can't build a legend handle for the bin glyphs. with_marginals turns this off for marginals, whose legend duplicates the main plot's.

False
transpose_labels bool

Place the value-axis label and the position ticks on the opposite axes from orientation. Use this only when the plot will be rendered transposed — as HoloViews' adjoint does for a right/left marginal, where it swaps the axes but not the tick labels. with_marginals sets this for the right marginal so its category ticks land on the (horizontal) position axis rather than the shared value axis. Leave it False for a standalone plot.

False

Returns:

Type Description
Overlay or NdOverlay

An Overlay for a single dataset, or an NdOverlay keyed by labels for multiple rows.

Raises:

Type Description
ValueError

If data is empty; if positions, bins, widths, color, or labels is given as a sequence of the wrong length; or for any reason raised by pavement.pavement_stats.

See Also

pavement.matplotlib.plot : The matplotlib equivalent. pavement_elements : The single-row element builder this wraps.

Examples:

>>> import holoviews as hv
>>> import pavement.holoviews as phv
>>> hv.extension('bokeh')
>>> phv.plot([1, 2, 3, 4, 5])

Split tidy data by category, then adjoin it as a top marginal::

main = hv.Scatter((x, y))
top = phv.plot(x, categories=group, orientation='horizontal')
layout = main << top                               # doctest: +SKIP
Source code in src/pavement/holoviews.py
def plot(
    data: Sequence[float] | Sequence[Iterable[float]],
    weights: Sequence[float] | Sequence[Sequence[float]] | None = None,
    positions: Sequence[float] | None = None,
    categories: Sequence[Hashable] | None = None,
    labels: Sequence[Hashable] | None = None,
    bins: int | None | Sequence[int | None] = 4,
    widths: float | Sequence[float] = 0.6,
    tassel_extent: float = 0.05,
    show_tassels: bool = False,
    show_box: bool | None = None,
    orientation: Literal["vertical", "horizontal"] = "vertical",
    value_label: str | None = None,
    value_format: ValueFormat | None = None,
    color: str | Sequence[str] | None = None,
    fill_alpha: float = 0.3,
    hover: bool = True,
    show_legend: bool = False,
    transpose_labels: bool = False,
) -> Any:
    """
    Build an interactive pavement plot as a HoloViews object.

    The HoloViews counterpart of `pavement.matplotlib.plot`. Accepts the
    same three input shapes — a single 1D dataset, a wide sequence of
    datasets, or tidy data plus *categories* — and returns a HoloViews
    object that renders through any backend.

    A single dataset returns a `holoviews.Overlay` (the bins, plus any
    tassels). Multiple rows return a `holoviews.NdOverlay` keyed by
    *labels*, which gives a legend and a consistent per-row color cycle;
    in tidy form this is the "split by category" case. Either result is
    a plain HoloViews object, so it composes with the framework: overlay
    it with ``*``, adjoin it as a marginal with ``<<``, or restyle it
    with ``.opts``.

    Parameters
    ----------
    data : sequence of float, or sequence of iterables of float
        The values to plot. Shape determines the mode, as in
        `pavement.matplotlib.plot`.
    weights : sequence, optional
        Positive weights, matching the shape of *data*.
    positions : sequence of float, optional
        Position of each row on the axis perpendicular to the value
        axis. Defaults to ``[1, 2, ..., N]``.
    categories : sequence, optional
        Category label per entry in *data* (tidy/long form). If given,
        *data* is split by category.
    labels : sequence, optional
        One label per row, used as the legend key and color order. In
        tidy form, also selects which categories to include and their
        order. Defaults to ``[1, 2, ..., N]`` (wide) or the sorted
        categories (tidy).
    bins : int, None, or sequence, default: 4
        Equal-mass bins per row; None shows all the data (a rug). A
        scalar applies to every row; a sequence sets each row and may
        mix None with integers. See `pavement.pavement_stats`.
    widths : float or sequence of float, default: 0.6
        Thickness of each row.
    tassel_extent : float, default: 0.05
        How far tassel marks extend beyond the box.
    show_tassels : bool, default: False
        Whether to draw tassel marks at repeated quantile values.
    show_box : bool or None, default: None
        Whether to draw each row's two long box edges. None (the default)
        draws them for a binned row and omits them for a rug
        (``bins=None``), so a rug reads like a plain rug plot; True or
        False forces it. Resolved per row, so a mixed *bins* sequence gets
        the right default for each.
    orientation : {'vertical', 'horizontal'}, default: 'vertical'
        Direction of the value axis.
    value_label : str, optional
        If given, label the value axis (x for horizontal, y otherwise).
        Defaults to ``None`` (unlabelled), as the ``matplotlib`` backend
        does; pass a string to label the axis.
    value_format : callable, optional
        Function mapping a value to its hover display string, e.g.
        ``lambda v: f"${v:,.2f}"``. Applies to the bin value ranges and
        tick values; defaults to 3 significant figures.
    color : str or sequence of str, optional
        Per-row color(s). A single color applies to every row; a
        sequence sets each row and must match the number of rows.
        Defaults to HoloViews' own color cycle, so a category-split
        pavement's groups match a default-colored main plot's groups
        (in the same key order) when used as a marginal.
    fill_alpha : float, default: 0.3
        Opacity of the bin fills. Bin borders (the ticks and box) are
        drawn opaque.
    hover : bool, default: True
        Whether to enable a hover tool (bokeh only; plotly hovers by
        default, matplotlib has none).
    show_legend : bool, default: False
        Whether to show the category legend (only relevant with multiple
        rows). Off by default; pass True to label the rows with a legend.
        Has no effect on matplotlib, which can't build a legend handle for
        the bin glyphs. `with_marginals` turns this off for marginals,
        whose legend duplicates the main plot's.
    transpose_labels : bool, default: False
        Place the value-axis label and the position ticks on the
        *opposite* axes from *orientation*. Use this only when the plot
        will be rendered transposed — as HoloViews' adjoint does for a
        right/left marginal, where it swaps the axes but not the tick
        labels. `with_marginals` sets this for the right marginal so its
        category ticks land on the (horizontal) position axis rather
        than the shared value axis. Leave it False for a standalone plot.

    Returns
    -------
    holoviews.Overlay or holoviews.NdOverlay
        An ``Overlay`` for a single dataset, or an ``NdOverlay`` keyed
        by *labels* for multiple rows.

    Raises
    ------
    ValueError
        If *data* is empty; if *positions*, *bins*, *widths*, *color*,
        or *labels* is given as a sequence of the wrong length; or for
        any reason raised by `pavement.pavement_stats`.

    See Also
    --------
    pavement.matplotlib.plot : The matplotlib equivalent.
    pavement_elements : The single-row element builder this wraps.

    Examples
    --------
    >>> import holoviews as hv
    >>> import pavement.holoviews as phv
    >>> hv.extension('bokeh')                              # doctest: +SKIP
    >>> phv.plot([1, 2, 3, 4, 5])                          # doctest: +SKIP

    Split tidy data by category, then adjoin it as a top marginal::

        main = hv.Scatter((x, y))
        top = phv.plot(x, categories=group, orientation='horizontal')
        layout = main << top                               # doctest: +SKIP
    """
    # labelled: whether to tick the position axis with per-row labels —
    # only when the rows mean something nameable (categories or explicit
    # labels), not for an anonymous single row at position 1.
    data, weight_rows, labels, labelled = normalize_rows(
        data, weights, categories, labels)
    n = len(data)
    if positions is None:
        positions = list(range(1, n + 1))
    elif len(positions) != n:
        raise ValueError(f"positions has length {len(positions)}, expected {n}")
    bins = broadcast(bins, n, "bins",
                     lambda v: v is None or isinstance(v, Integral))
    widths = broadcast(widths, n, "widths", lambda v: isinstance(v, Number))
    colors = resolve_colors(color, n, _default_colors)

    rows = {}
    for label, dataset, w, pos, b, width, col in zip(
            labels, data, weight_rows, positions, bins, widths, colors):
        group = label if n > 1 else None
        els = pavement_elements(
            dataset, bins=b, weights=w, position=pos, width=width,
            tassel_extent=tassel_extent, show_tassels=show_tassels,
            show_box=show_box, orientation=orientation, group=group,
            value_format=value_format)
        # Fill behind (hover target), then the box edges, then the ticks.
        parts = [_style(els[role], role, col, fill_alpha, hover)
                 for role in ("fill", "box", "ticks")]
        # bokeh hovers the glyphs directly; plotly draws them as
        # non-hoverable shapes, so add an invisible marker layer there.
        if hover and hv.Store.current_backend == "plotly":
            parts.append(_plotly_hover_layer(els["fill"], orientation))
        rows[label] = hv.Overlay(parts)

    if n == 1:
        result = rows[labels[0]]
    else:
        result = hv.NdOverlay(rows, kdims="group")

    # Label the value axis; the perpendicular (position) axis carries
    # the row labels as ticks when the rows are nameable, else nothing —
    # its bare "x0"/"y0" dimension name is never meaningful here. When
    # the plot will be displayed transposed (transpose_labels), put the
    # labels and ticks on the swapped axes so they still match the data.
    value_axis = "x" if orientation == "horizontal" else "y"
    pos_axis = "y" if orientation == "horizontal" else "x"
    if transpose_labels:
        value_axis, pos_axis = pos_axis, value_axis
    # An unlabelled value axis (the default) is blanked, not left to fall
    # back to the bare "x0"/"y0" dimension name HoloViews would otherwise show.
    value_text = "" if value_label is None else value_label
    opts: dict[str, Any] = {f"{value_axis}label": value_text, f"{pos_axis}label": ""}
    if labelled:
        opts[f"{pos_axis}ticks"] = [
            (pos, str(label)) for pos, label in zip(positions, labels)]
    if n > 1:
        # matplotlib can't build a legend handle for Rectangles glyphs;
        # the legend is otherwise an interactive-backend feature.
        opts["show_legend"] = show_legend and hv.Store.current_backend != "matplotlib"
    # bokeh merges the per-element hover tools into one bound to a single
    # glyph; a finalize hook rebinds it to every row's fills and ticks.
    if hover and hv.Store.current_backend == "bokeh":
        opts["hooks"] = [_bokeh_hover_hook]
    return result.opts(**opts)

with_marginals

with_marginals(
    main: Any,
    x: Sequence[float] | None = None,
    y: Sequence[float] | None = None,
    categories: Sequence[Hashable] | None = None,
    x_label: str = "x",
    y_label: str = "y",
    size: int | None = None,
    **kwargs: Any,
) -> Any

Adjoin pavement marginals to a plot — x on top, y on the right.

A one-call joint-plot helper that hides the things you would otherwise have to know to adjoin a pavement with HoloViews' << operator: that each marginal must be built with orientation='horizontal' (HoloViews orients each adjoined slot to share the main plot's axis), that << fills the right slot before the top, and how to keep the marginal strips thin. Pass the marginal data and it places them correctly.

The marginals are drawn as thin strips with their (redundant) category legends turned off, so they don't crowd the main plot. With categories, each is split by category; leaving color at its default (see plot) makes the groups match a default-colored main plot, so a colored scatter and its marginals share one color scheme for free.

Parameters:

Name Type Description Default
main holoviews object

The central plot, e.g. a holoviews.Scatter or an NdOverlay of them.

required
x sequence of float

Data for the top (x) and right (y) marginals. Provide either or both; at least one is required. For a category split, these are the per-point x and y values in tidy form, parallel to categories.

None
y sequence of float

Data for the top (x) and right (y) marginals. Provide either or both; at least one is required. For a category split, these are the per-point x and y values in tidy form, parallel to categories.

None
categories sequence

Category label per point, parallel to x and y. Splits each marginal by category, as in plot.

None
x_label str

Value-axis labels for the top and right marginals.

'x', 'y'
y_label str

Value-axis labels for the top and right marginals.

'x', 'y'
size int

Thickness of each marginal strip in pixels (bokeh/plotly; the matplotlib adjoint sizes its own). Defaults to roughly 40px per category, so multi-group marginals stay legible. Pass a larger value to give crowded categories more room.

None
**kwargs Any

Forwarded to plot for both marginals (e.g. bins, color, fill_alpha, show_tassels, show_legend, value_format). orientation is set automatically and must not be passed; show_legend defaults to False here but may be overridden.

{}

Returns:

Type Description
AdjointLayout

main with the requested marginals adjoined.

Raises:

Type Description
ValueError

If neither x nor y is given, or if orientation is passed in kwargs (it is chosen automatically).

See Also

plot : Builds each marginal; call it directly for finer control.

Examples:

>>> import pavement.holoviews as phv
>>> scatter = hv.NdOverlay(...)
>>> phv.with_marginals(scatter, x=xs, y=ys,
...                    categories=groups)
Source code in src/pavement/holoviews.py
def with_marginals(
    main: Any,
    x: Sequence[float] | None = None,
    y: Sequence[float] | None = None,
    categories: Sequence[Hashable] | None = None,
    x_label: str = "x",
    y_label: str = "y",
    size: int | None = None,
    **kwargs: Any,
) -> Any:
    """
    Adjoin pavement marginals to a plot — x on top, y on the right.

    A one-call joint-plot helper that hides the things you would
    otherwise have to know to adjoin a pavement with HoloViews' ``<<``
    operator: that each marginal must be built with
    ``orientation='horizontal'`` (HoloViews orients each adjoined slot to
    share the main plot's axis), that ``<<`` fills the right slot before
    the top, and how to keep the marginal strips thin. Pass the marginal
    data and it places them correctly.

    The marginals are drawn as thin strips with their (redundant)
    category legends turned off, so they don't crowd the main plot. With
    *categories*, each is split by category; leaving *color* at its
    default (see `plot`) makes the groups match a default-colored
    *main* plot, so a colored scatter and its marginals share one color
    scheme for free.

    Parameters
    ----------
    main : holoviews object
        The central plot, e.g. a `holoviews.Scatter` or an
        ``NdOverlay`` of them.
    x, y : sequence of float, optional
        Data for the top (x) and right (y) marginals. Provide either or
        both; at least one is required. For a category split, these are
        the per-point x and y values in tidy form, parallel to
        *categories*.
    categories : sequence, optional
        Category label per point, parallel to *x* and *y*. Splits each
        marginal by category, as in `plot`.
    x_label, y_label : str, default: 'x', 'y'
        Value-axis labels for the top and right marginals.
    size : int, optional
        Thickness of each marginal strip in pixels (bokeh/plotly; the
        matplotlib adjoint sizes its own). Defaults to roughly 40px per
        category, so multi-group marginals stay legible. Pass a larger
        value to give crowded categories more room.
    **kwargs
        Forwarded to `plot` for both marginals (e.g. *bins*,
        *color*, *fill_alpha*, *show_tassels*, *show_legend*,
        *value_format*).
        *orientation* is set automatically and must not be passed;
        *show_legend* defaults to False here but may be overridden.

    Returns
    -------
    holoviews.AdjointLayout
        *main* with the requested marginals adjoined.

    Raises
    ------
    ValueError
        If neither *x* nor *y* is given, or if *orientation* is passed
        in *kwargs* (it is chosen automatically).

    See Also
    --------
    plot : Builds each marginal; call it directly for finer control.

    Examples
    --------
    >>> import pavement.holoviews as phv
    >>> scatter = hv.NdOverlay(...)                          # doctest: +SKIP
    >>> phv.with_marginals(scatter, x=xs, y=ys,
    ...                    categories=groups)                # doctest: +SKIP
    """
    if x is None and y is None:
        raise ValueError("provide x and/or y data for the marginals")
    if "orientation" in kwargs:
        raise ValueError(
            "orientation is chosen automatically by with_marginals; "
            "call plot directly if you need to set it")
    if "transpose_labels" in kwargs:
        raise ValueError(
            "transpose_labels is set per slot by with_marginals; "
            "call plot directly if you need to control it")
    kwargs.setdefault("show_legend", False)

    if size is None:
        n_groups = len(set(categories)) if categories is not None else 1
        size = 40 * n_groups + 30

    def strip(data: Sequence[float], label: str, dim: str,
              transpose: bool) -> Any:
        pav = plot(data, categories=categories, orientation="horizontal",
                       value_label=label, transpose_labels=transpose, **kwargs)
        return _thin(pav, dim, size)

    layout = main
    # `<<` fills the right slot first, then the top. Add y (right) before
    # x (top); for an x-only marginal, hold the right slot open with an
    # Empty so x still lands on top rather than the right. The right slot
    # is rendered transposed, so its strip is thinned in width (not
    # height) and its labels are transposed to keep the axes sensible.
    if y is not None:
        layout = layout << strip(y, y_label, "width", transpose=True)
    elif x is not None:
        layout = layout << hv.Empty()
    if x is not None:
        layout = layout << strip(x, x_label, "height", transpose=False)
    return layout

pandas integration (pavement.pandas)

Importing this module registers the .pave accessor on pandas DataFrame and Series.

pandas

Pandas integration: a .pave accessor and an opt-in summary repr.

Importing this module registers a .pave accessor on pandas DataFrame, Series, SeriesGroupBy, and DataFrameGroupBy (the first two through pandas' own accessor API; the GroupBy ones via a compatible descriptor, since pandas exposes no public registration hook for them), putting the pavement strips a method away::

import pavement.pandas          # registers .pave

df.pave()                       # the whole-frame summary (renders inline)
df.pave.summary()               # the same, spelled out
df.pave.spark("price")          # a numeric column's pavement sparkline
df.pave.tally("plan")           # a column's distinct/duplicate/missing strip
df.pave.proportion("plan")      # a column's value-counts strip

s = df["price"]
s.pave()                        # a Series summarizes as one row
s.pave.spark()                  # the column helpers take no column name

df["score"].groupby(df["team"]).pave()   # one row per group (SeriesGroupBy)
df.groupby("team").pave()               # one row per group (DataFrameGroupBy)

A labeled Series — say a groupby aggregate — also gets a .pebbles shortcut for the one-value-per-label view (s.pave(pebbles=True) spelled shorter)::

df.groupby("team")["score"].mean().pebbles()   # one pebble per team

df.pave() and .summary() return the same pavement.summary result (a Summary, which renders inline in Jupyter). The single-column helpers return the svg string of the matching pavement.svg glyph, wrapped so it also renders inline in a notebook while still behaving as the plain string everywhere else (it is a str subclass).

Optionally make the summary a frame's default notebook display::

pavement.pandas.enable_repr()   # every DataFrame/Series previews as a summary
pavement.pandas.disable_repr()  # restore pandas' normal display

That one replaces the usual data-table preview (it does not append to it), so it is strictly opt-in. It needs a running IPython/Jupyter; importing the accessor needs only pandas.

This module follows the same pattern as import hvplot.pandas — the integration activates on import, never on a bare import pavement, so the core package stays dependency-free.

enable_repr

enable_repr(
    series: bool = True, **summary_kwargs: Any
) -> None

Make pavement.summary the default inline display of pandas objects.

Registers an IPython HTML formatter so that every DataFrame (and, unless series is False, every Series) renders as its pavement summary in the notebook, replacing the usual data-table preview rather than adding to it. Extra keyword arguments are forwarded to summary (e.g. height, color). Undo with disable_repr.

Raises RuntimeError if there is no running IPython/Jupyter session.

Source code in src/pavement/pandas.py
def enable_repr(series: bool = True, **summary_kwargs: Any) -> None:
    """Make `pavement.summary` the default inline display of pandas objects.

    Registers an IPython HTML formatter so that every ``DataFrame`` (and,
    unless *series* is False, every ``Series``) renders as its pavement
    summary in the notebook, **replacing** the usual data-table preview rather
    than adding to it. Extra keyword arguments are forwarded to `summary`
    (e.g. ``height``, ``color``). Undo with `disable_repr`.

    Raises ``RuntimeError`` if there is no running IPython/Jupyter session.
    """
    types = [pd.DataFrame] + ([pd.Series] if series else [])
    enable_summary_repr(types, **summary_kwargs)

disable_repr

disable_repr() -> None

Restore pandas' normal display, undoing enable_repr.

Source code in src/pavement/pandas.py
def disable_repr() -> None:
    """Restore pandas' normal display, undoing `enable_repr`."""
    disable_summary_repr([pd.DataFrame, pd.Series])

polars integration (pavement.polars)

Importing this module registers the .pave namespace on polars DataFrame and Series.

polars

Polars integration: a .pave namespace and an opt-in summary repr.

The polars counterpart of pavement.pandas. Importing this module registers a .pave namespace on polars DataFrame, Series, and GroupBy (DataFrame and Series through polars' own namespace API; GroupBy via a compatible descriptor, since polars exposes no public registration hook for GroupBy types), putting the pavement strips a method away::

import pavement.polars         # registers .pave

df.pave()                      # the whole-frame summary (renders inline)
df.pave.summary()              # the same, spelled out
df.pave.spark("price")         # a numeric column's pavement sparkline
df.pave.tally("plan")          # a column's distinct/duplicate/missing strip
df.pave.proportion("plan")     # a column's value-counts strip

s = df["price"]
s.pave()                       # a Series summarizes as one row
s.pave.spark()                 # the column helpers take no column name

df.group_by("team").pave()     # one row per group

A polars Series has no index, so the one-value-per-label "pebbles" view comes from a two-column (labels, values) frame instead (its second column names the pooled header)::

df.group_by("team").agg(pl.col("score").mean()).pebbles()   # one per team

df.pave() / .summary() return the pavement.summary result (a Summary, which renders inline in Jupyter); the single-column helpers return the matching pavement.svg glyph's string, wrapped so it also renders inline (it is a str subclass — see pavement._inline.SVG).

Optionally make the summary a frame's default notebook display::

pavement.polars.enable_repr()   # every DataFrame/Series previews as a summary
pavement.polars.disable_repr()  # restore polars' normal display

That replaces the usual data-table preview (it does not append), so it is strictly opt-in, and needs a running IPython/Jupyter. The integration activates on import pavement.polars only, never on a bare import pavement, keeping the core dependency-free.

enable_repr

enable_repr(
    series: bool = True, **summary_kwargs: Any
) -> None

Make pavement.summary the default inline display of polars objects.

Registers an IPython HTML formatter so that every DataFrame (and, unless series is False, every Series) renders as its pavement summary in the notebook, replacing polars' usual data-table preview. Extra keyword arguments are forwarded to summary (e.g. height, color). Undo with disable_repr.

Raises RuntimeError if there is no running IPython/Jupyter session.

Source code in src/pavement/polars.py
def enable_repr(series: bool = True, **summary_kwargs: Any) -> None:
    """Make `pavement.summary` the default inline display of polars objects.

    Registers an IPython HTML formatter so that every ``DataFrame`` (and,
    unless *series* is False, every ``Series``) renders as its pavement
    summary in the notebook, **replacing** polars' usual data-table preview.
    Extra keyword arguments are forwarded to `summary` (e.g. ``height``,
    ``color``). Undo with `disable_repr`.

    Raises ``RuntimeError`` if there is no running IPython/Jupyter session.
    """
    types = [pl.DataFrame] + ([pl.Series] if series else [])
    enable_summary_repr(types, **summary_kwargs)

disable_repr

disable_repr() -> None

Restore polars' normal display, undoing enable_repr.

Source code in src/pavement/polars.py
def disable_repr() -> None:
    """Restore polars' normal display, undoing `enable_repr`."""
    disable_summary_repr([pl.DataFrame, pl.Series])