Pebble Bar Chart#

The pebble bar chart is a logarithmic-density stacked bar chart where every data point is individually visible. Items stack from large squares at the bottom to tiny ones at the top, creating a natural visual hierarchy that shows both exact counts and relative magnitudes — like a histogram where every data point gets its own pixel.

Each bar gets a watercolor wash by default — a turbulence texture that gives the chart a hand-painted, organic look. Items with images sit on top of the wash; items without images are represented by the wash alone.

Every bar is drawn in Python (numpy and Pillow) to a single image: the layout, pictures, white outlines, rounded corners and watercolor all go into it. The same images feed three outputs:

Function

Output

Use it for

pebble_bar_chart

Interactive HTML page

Reports where items show tooltips and link to detail pages

pebble_bar_figure

matplotlib figure

Static PNG/PDF charts and notebooks

write_pebble_assets

One image per bar + manifest.json

Websites that place the chart in their own page with pebble-view.js

The browser never draws pebbles. The HTML page loads one image per bar and a small script (pebble-view.js) that maps the pointer to the item under it, so a chart with thousands of items loads as fast as one with ten.

Quick start#

from matviz import pebble_bar_chart

categories = [
    {"name": "trucks", "label": "Trucks", "color": "#b87200",
     "items": [{"id": str(i)} for i in range(87)]},
    {"name": "vans", "label": "Vans", "color": "#186b4e",
     "items": [{"id": str(i)} for i in range(54)]},
    {"name": "sedans", "label": "Sedans", "color": "#4e82b8",
     "items": [{"id": str(i)} for i in range(18)]},
]

pebble_bar_chart(
    categories,
    "fleet.html",
    title="Vehicle Fleet",
    subtitle="Each square = one vehicle",
    bar_width=150,
    log_base=1.1,
    item_offset=16,
    h_squeeze=0.7,
    bar_gap=21,
)

Open fleet.html in a browser to see the chart. The page is a single file: the bar images are embedded in it.

The same chart as a static matplotlib figure:

from matviz import pebble_bar_figure

fig = pebble_bar_figure(categories, bar_width=150, log_base=1.1,
                        item_offset=16, h_squeeze=0.7, bar_gap=21)
fig.savefig("fleet.png", dpi=144, bbox_inches="tight")

How it works#

Each bar is a vertical stack of rows. Row r (counting from 0 at the bottom) has floor(base^r) columns. Each cell is barWidth / base^r pixels wide and tall (square). The logarithmic stacking means:

  • Bottom rows: 1–2 large squares per row (prominent, individually visible)

  • Top rows: many tiny squares packed together (conveys volume)

The log_base parameter controls how fast columns multiply:

log_base

Behavior

√2 ≈ 1.414 (default)

Columns double every 2 rows — balanced

1.1

Slow growth — tall, narrow pyramids

2.0

Fast growth — short, wide pyramids

Item data#

Each item in a category can carry:

Key

Type

Description

id

str

Unique identifier

label

str

Tooltip text on hover

link

str

URL opened on click

src

str

Local image file drawn in the cell (relative to image_dir)

image

PIL.Image

An already-loaded image, instead of src

items = [
    {"id": "v001", "label": "Truck #1", "link": "https://example.com/v001",
     "src": "images/v001.png"},
    {"id": "v002", "label": "Truck #2"},  # represented by the watercolor wash
]
pebble_bar_chart(categories, "fleet.html", image_dir="data/")

Pictures are stretched to fill their square, given a white outline that follows the image’s alpha mask (outline_radius, default 1px) and rounded corners. Transparent cutouts (PNG with alpha) look best. Items without images are represented by the wash alone. Images must be local files: download remote images first, or pass image_loader= a function that returns a PIL image for an item. A missing file stops the build with the item’s id.

Watercolor styling#

Watercolor is on by default — the color you set on each category is used for the wash. You only need watercolor_color if you want the wash to be a different shade from the base color:

categories = [
    # Watercolor uses the color automatically
    {"name": "alpha", "label": "Alpha", "color": "#b87200",
     "items": [...]},

    # Override: base color is gray, but wash is amber
    {"name": "beta", "label": "Beta", "color": "#888",
     "watercolor_color": "#b87200",
     "items": [...]},

    # Opt out of watercolor entirely — flat solid squares
    {"name": "gamma", "label": "Gamma", "color": "#4e82b8",
     "watercolor_color": "",
     "items": [...]},
]

Key parameters#

Parameter

Default

Description

bar_width

120

Pixel width of each bar before squeeze

log_base

√2

Log base controlling density growth

item_offset

0

Invisible padding items to skip sparse bottom rows

h_squeeze

1.0

Horizontal squeeze factor (0–1)

bar_gap

8

Pixels between adjacent bars

outline_radius

1

White outline around image items (0 to disable)

sort_desc

True

Sort categories largest first

show_count

True

Show item count next to labels

scale

2

Image pixels per CSS pixel. 2 is sharp on most high-density screens; 3 is a little sharper on some phones at about twice the file size

supersample

4

Drawing resolution multiplier before shrinking to scale. Keeps the sub-pixel pebbles at the top of big bars visible as dots

seed

0

Fixes which pebble overlaps which and the watercolor texture. The same seed always gives identical images

embed_images

True

(pebble_bar_chart) Embed the bar images in the HTML. False writes them to <name>_files/ beside the page

image_dir

None

Base folder for relative src paths

open_browser

False

Open the file in the default browser

Using the chart on your own website#

write_pebble_assets writes one image per bar and a manifest.json. Copy matviz/_assets/pebble-view.js into the site and draw the chart with it:

from matviz import write_pebble_assets

write_pebble_assets(categories, "site/static/chart/", bar_width=150,
                    log_base=1.1, item_offset=16, h_squeeze=0.7, bar_gap=21)
<div id="chart"></div>
<script src="pebble-view.js"></script>
<script>
  fetch("/static/chart/manifest.json").then(r => r.json()).then(data => {
    const chart = renderPebbleView(document.getElementById("chart"), data,
                                   { baseUrl: "/static/chart/" });
    // chart.yAxis can be moved outside a horizontal scroll container
    // to keep the axis pinned; chart.bars are the bar elements.
  });
</script>

The images are built once, offline. Rebuild them only when the data or chart options change.

Real-world examples#

What Blocks Bike Lanes?#

Pebble bar chart of bike lane obstructions

Urban infrastructure data visualized as a pebble bar chart. View the article →

Full example#

See examples/pebble_bar_example.py in the repository for a complete working example with multiple categories.

API reference#

matviz.pebble_bar.pebble_bar_chart(categories: Sequence[Dict[str, Any]], output: str | Path | None = None, *, title: str = '', subtitle: str = '', embed_images: bool = True, font_family: str = 'system-ui, -apple-system, sans-serif', extra_css: str = '', extra_js: str = '', open_browser: bool = False, **opts) → str[source]#

Generate an interactive pebble bar chart as an HTML page.

The bars are drawn in Python; the page only shows the bar images and maps the pointer to the item under it (tooltip from label or id, click opens link).

Parameters:
categorieslist of dict

Each category dict has name, label, color (also the default watercolor wash), optional watercolor_color (None or "" for flat squares without a wash), and items: dicts with id, label (tooltip), link (URL) and src (local image path) or image (PIL image).

outputstr or Path or None

Path to write the HTML file. If None, the HTML string is returned without writing to disk.

title, subtitlestr

Heading and description line.

embed_imagesbool

True (default) inlines the bar images as data URIs so the chart is a single file. False writes them to <output stem>_files/ beside the page (requires output).

font_family, extra_css, extra_js

Page styling and extra script run after the chart is drawn (chart holds the return value of renderPebbleView).

open_browserbool

Open the file in the default browser after writing.

**opts

Chart options for render_pebble_chart(): bar_width (default 120), log_base (√2), item_offset (0), h_squeeze (1.0), bar_gap (8), sort_desc, show_count, outline_radius (1), scale (2), supersample (4), seed (0), image_dir, …

Returns:
str

The HTML string. Also written to output if provided.

Examples

>>> categories = [
...     {"name": "trucks", "label": "Trucks", "color": "#b87200",
...      "items": [{"id": str(i)} for i in range(45)]},
...     {"name": "vans", "label": "Vans", "color": "#186b4e",
...      "items": [{"id": str(i)} for i in range(30)]},
... ]
>>> html = pebble_bar_chart(categories, "fleet.html",
...                         title="Vehicle Fleet", log_base=1.1,
...                         item_offset=16, h_squeeze=0.7)
matviz.pebble_bar.pebble_bar_figure(categories: Sequence[Dict[str, Any]], *, ax=None, figsize=None, label_rotation=0, **opts)[source]#

Draw a static pebble bar chart with matplotlib.

Takes the same options as render_pebble_chart(). Pass ax to draw into existing axes. One axes unit is one CSS pixel of the HTML chart. Returns the figure.

matviz.pebble_bar.write_pebble_assets(categories: Sequence[Dict[str, Any]], out_dir: str | Path, *, manifest_name: str = 'manifest.json', image_format: str = 'webp', quality: int = 90, item_fields: Sequence[str] = ('id', 'link', 'label'), **opts) → Dict[str, Any][source]#

Write one image per bar and a manifest for pebble-view.js.

Writes <out_dir>/<name>.<image_format> for each category and <out_dir>/<manifest_name>. Image paths in the manifest are relative to the manifest, so pass baseUrl (the manifest’s folder URL) to renderPebbleView. Other keyword arguments go to render_pebble_chart(). Returns the manifest.

matviz.pebble_bar.render_pebble_chart(categories: Sequence[Dict[str, Any]], *, bar_width: float = 120, log_base: float = 1.4142135623730951, item_offset: int = 0, h_squeeze: float = 1.0, gutter: float = 0, bar_gap: float = 8, sort_desc: bool = True, show_count: bool = True, outline_radius: float = 1, background: str = '#faf8f2', scale: int = 2, supersample: int = 4, seed: int = 0, image_dir: str | Path | None = None, image_loader=None, min_tick: int = 3, skip_ticks: Sequence[int] = (1000,)) → PebbleChart[source]#

Render every bar of a pebble chart (the shared step of all outputs).

Parameters:
categorieslist of dict

Each dict has name, label, color (flat fill and default watercolor), optional watercolor_color (falsy for no wash) and items. Items are drawn bottom-first in list order.

bar_width, log_base, item_offset, h_squeeze, gutter

Layout; see matviz.pebble_layout.bar_rows().

bar_gapfloat

CSS px between bars.

sort_descbool

Sort categories by item count, largest first.

show_countbool

Show each bar’s item count beside its label.

outline_radiusfloat

White outline around pictures, in CSS px (0 = none).

backgroundstr

Page colour the watercolor is blended onto.

scaleint

Image pixels per CSS px. 2 is sharp on most high-density screens; 3 is a little sharper on some phones at about twice the file size.

supersampleint

Drawing resolution multiplier before shrinking to scale; higher keeps more of the tiny top-row pebbles.

seedint

Seed for the overlap order and the watercolor texture. Each bar uses seed mixed with its name, so rebuilding gives the same images.

image_dirpath, optional

Base directory for relative item src paths.

image_loadercallable, optional

image_loader(item) -> PIL.Image to load pictures some other way.

min_tick, skip_ticks

Y-axis ticks: 1-2-5 values from min_tick up, minus skip_ticks.

matviz.pebble_render.render_bar(items: Sequence[Dict[str, Any]], *, bar_width=120, log_base=1.4142135623730951, item_offset=0, gutter=0, h_squeeze=1.0, color='#888888', watercolor_color=None, background='#faf8f2', image_loader: Callable[[Dict[str, Any]], Image] | None = None, image_dir=None, outline_radius=1.0, corner_radius=None, scale=2, supersample=4, seed=0, wash_seeds=None) → PebbleBar[source]#

Draw one pebble bar to an RGBA image.

Parameters:
itemslist of dict

Bar items, bottom (largest) first. Items with src (a local file path, resolved against image_dir) or image (a PIL image) are drawn as pictures. Without a picture, an item is a solid color square when there is no watercolor and is left to the wash otherwise.

bar_width, log_base, item_offset, gutter, h_squeeze

Layout, as in matviz.pebble_layout.bar_rows().

colorstr

Fill for items without a picture (flat mode only).

watercolor_colorstr or None

Colour of the watercolor wash behind the bar; falsy for no wash.

backgroundstr

Page colour the wash is blended onto (the bar itself is opaque).

image_loadercallable, optional

image_loader(item) -> PIL.Image. Defaults to default_image_loader() with image_dir.

outline_radiusfloat

White outline around each picture’s alpha, in CSS px (0 = none).

corner_radiusfloat or callable, optional

Corner radius in CSS px, or f(size). Default max(1, min(3, size * 0.05)).

scaleint

Output pixels per CSS px (2 = sharp on most high-density screens).

supersampleint

Extra drawing resolution before shrinking to scale.

seedint

Seed for which pebble overlaps which; the same seed gives the same image.

wash_seeds(int, int), optional

Noise seeds for the wash grain and edge warp. Default derived from seed.

Returns:
PebbleBar
matviz.pebble_layout.bar_rows(n_items, *, bar_width=120, log_base=1.4142135623730951, item_offset=0, gutter=0, h_squeeze=1.0) → List[Row][source]#

Rows holding at least one real item, from the bottom up.