Real-Time Dashboard Tiles — Pygal

A dashboard layout displaying multiple metric tiles in a responsive grid, where each tile shows a KPI value with its label, an embedded sparkline showing recent trend, and a change indicator (up/down arrow with percentage). This visualization is essential for operations monitoring and business dashboards where multiple metrics need to be tracked simultaneously at a glance. The combination of current value, trend visualization, and change direction provides comprehensive metric context in a compact format.

Real-Time Dashboard Tiles rendered with Pygal

Python source (Pygal)

""" anyplot.ai
dashboard-metrics-tiles: Real-Time Dashboard Tiles
Library: pygal 3.1.0 | Python 3.13.13
Quality: 88/100 | Updated: 2026-05-21
"""

import os
import sys


# Remove the script's own directory from sys.path to prevent circular import
# (this file is named pygal.py, same as the installed package).
_script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path = [p for p in sys.path if os.path.abspath(p or ".") != _script_dir]

from io import BytesIO

import cairosvg
import numpy as np
import pygal
from PIL import Image, ImageDraw, ImageFont
from pygal.style import Style


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"

# Semantic status colors (Okabe-Ito positions)
STATUS_COLORS = {"good": "#009E73", "warning": "#DDCC77", "critical": "#AE3030"}  # imprint semantic anchors
CHANGE_POSITIVE = "#009E73"
CHANGE_NEGATIVE = "#AE3030"  # imprint red — negative change
SPARKLINE_COLOR = "#4467A3"  # Okabe-Ito position 3

# Data
np.random.seed(42)
metrics = [
    {
        "name": "CPU Usage",
        "value": 45,
        "unit": "%",
        "change": -5.2,
        "status": "good",
        "history": [52, 48, 55, 51, 47, 50, 48, 45, 46, 44, 45],
        "lower_is_better": True,
    },
    {
        "name": "Memory",
        "value": 72,
        "unit": "%",
        "change": 8.3,
        "status": "warning",
        "history": [65, 66, 68, 67, 70, 69, 71, 70, 72, 71, 72],
        "lower_is_better": True,
    },
    {
        "name": "Response Time",
        "value": 120,
        "unit": "ms",
        "change": -15.0,
        "status": "good",
        "history": [145, 142, 138, 135, 130, 128, 125, 122, 121, 120, 120],
        "lower_is_better": True,
    },
    {
        "name": "Error Rate",
        "value": 2.1,
        "unit": "%",
        "change": 45.0,
        "status": "critical",
        "history": [1.2, 1.4, 1.3, 1.5, 1.6, 1.8, 1.9, 2.0, 2.0, 2.1, 2.1],
        "lower_is_better": True,
    },
    {
        "name": "Throughput",
        "value": 1250,
        "unit": "req/s",
        "change": 12.5,
        "status": "good",
        "history": [1100, 1120, 1150, 1180, 1200, 1210, 1220, 1230, 1240, 1245, 1250],
        "lower_is_better": False,
    },
    {
        "name": "Active Users",
        "value": 3420,
        "unit": "",
        "change": 5.8,
        "status": "good",
        "history": [3200, 3220, 3280, 3310, 3350, 3380, 3390, 3400, 3410, 3415, 3420],
        "lower_is_better": False,
    },
]

# Canvas layout (3200x1800 landscape — hard contract)
CANVAS_WIDTH = 3200
CANVAS_HEIGHT = 1800
TITLE_HEIGHT = 120
MARGIN = 30
GAP = 30
GRID_COLS = 3
GRID_ROWS = 2

grid_width = CANVAS_WIDTH - 2 * MARGIN
grid_height = CANVAS_HEIGHT - TITLE_HEIGHT - 2 * MARGIN
tile_width = (grid_width - (GRID_COLS - 1) * GAP) // GRID_COLS
tile_height = (grid_height - (GRID_ROWS - 1) * GAP) // GRID_ROWS

TILE_PADDING = 40
STATUS_BAR_H = 10
SPARKLINE_W = tile_width - 2 * TILE_PADDING
SPARKLINE_H = 340

# Pygal sparkline style (transparent background)
sparkline_style = Style(
    background="transparent",
    plot_background="transparent",
    foreground="transparent",
    foreground_strong="transparent",
    foreground_subtle="transparent",
    colors=(SPARKLINE_COLOR,),
    stroke_width=5,
)

# Load fonts
try:
    title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 68)
    value_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 100)
    name_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 46)
    change_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 52)
    trend_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 36)
except OSError:
    title_font = ImageFont.load_default()
    value_font = ImageFont.load_default()
    name_font = ImageFont.load_default()
    change_font = ImageFont.load_default()
    trend_font = ImageFont.load_default()

# Create canvas
canvas = Image.new("RGB", (CANVAS_WIDTH, CANVAS_HEIGHT), PAGE_BG)
draw = ImageDraw.Draw(canvas)

# Title
title_text = "dashboard-metrics-tiles · python · pygal · anyplot.ai"
bbox = draw.textbbox((0, 0), title_text, font=title_font)
title_w = bbox[2] - bbox[0]
draw.text(((CANVAS_WIDTH - title_w) // 2, 32), title_text, fill=INK, font=title_font)

# Metric tiles — 3x2 grid
for idx, metric in enumerate(metrics):
    row = idx // GRID_COLS
    col = idx % GRID_COLS
    tx = MARGIN + col * (tile_width + GAP)
    ty = TITLE_HEIGHT + MARGIN + row * (tile_height + GAP)

    # Tile background
    draw.rounded_rectangle([tx, ty, tx + tile_width, ty + tile_height], radius=18, fill=ELEVATED_BG)

    # Coloured status bar at top of tile
    draw.rectangle([tx, ty, tx + tile_width, ty + STATUS_BAR_H], fill=STATUS_COLORS[metric["status"]])

    # Metric name
    cy = ty + STATUS_BAR_H + TILE_PADDING
    draw.text((tx + TILE_PADDING, cy), metric["name"], fill=INK_SOFT, font=name_font)
    cy += 65

    # Current value with unit
    value_text = f"{metric['value']:,}{metric['unit']}"
    draw.text((tx + TILE_PADDING, cy), value_text, fill=INK, font=value_font)
    cy += 130

    # Change indicator (arrow + percentage)
    change = metric["change"]
    favorable = (change < 0) if metric["lower_is_better"] else (change > 0)
    change_color = CHANGE_POSITIVE if favorable else CHANGE_NEGATIVE
    arrow = "▲" if change > 0 else "▼"
    draw.text((tx + TILE_PADDING, cy), f"{arrow} {abs(change):.1f}%", fill=change_color, font=change_font)

    # Separator + trend label above sparkline to fill the vertical gap
    sp_y = ty + tile_height - SPARKLINE_H - TILE_PADDING
    rule_y = sp_y - 52
    draw.line([(tx + TILE_PADDING, rule_y), (tx + tile_width - TILE_PADDING, rule_y)], fill=INK_SOFT, width=2)
    draw.text((tx + TILE_PADDING, rule_y + 10), "7-day trend", fill=INK_SOFT, font=trend_font)

    # Sparkline (full tile width, anchored to bottom of tile)
    sp_chart = pygal.Line(
        width=SPARKLINE_W,
        height=SPARKLINE_H,
        style=sparkline_style,
        show_legend=False,
        show_dots=False,
        show_y_labels=False,
        show_x_labels=False,
        show_y_guides=False,
        show_x_guides=False,
        margin=0,
        spacing=0,
        fill=True,
        stroke_style={"width": 5, "linecap": "round", "linejoin": "round"},
    )
    sp_chart.add("", metric["history"])
    sp_svg = sp_chart.render()
    sp_png = cairosvg.svg2png(bytestring=sp_svg, output_width=SPARKLINE_W, output_height=SPARKLINE_H)
    sp_img = Image.open(BytesIO(sp_png)).convert("RGBA")
    canvas.paste(sp_img, (tx + TILE_PADDING, sp_y), sp_img)

# Save PNG
canvas.save(f"plot-{THEME}.png")

# Interactive HTML with pygal SVG sparklines
html_parts = [
    f"""<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>dashboard-metrics-tiles · python · pygal · anyplot.ai</title>
    <style>
        * {{ box-sizing: border-box; margin: 0; padding: 0; }}
        body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
               background: {PAGE_BG}; padding: 40px; }}
        h1 {{ text-align: center; color: {INK}; font-size: 26px; margin-bottom: 32px; }}
        .dashboard {{ display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px;
                     max-width: 1400px; margin: 0 auto; }}
        .tile {{ background: {ELEVATED_BG}; border-radius: 12px; padding: 24px;
                position: relative; overflow: hidden; }}
        .status-bar {{ position: absolute; top: 0; left: 0; right: 0; height: 4px; }}
        .metric-name {{ color: {INK_SOFT}; font-size: 15px; margin-bottom: 8px; }}
        .metric-value {{ color: {INK}; font-size: 38px; font-weight: bold; margin-bottom: 8px; }}
        .metric-change {{ font-size: 16px; font-weight: 600; margin-bottom: 12px; }}
        .change-pos {{ color: #009E73; }}
        .change-neg {{ color: #C475FD; }}
        .trend-separator {{ border: none; border-top: 1px solid {INK_SOFT}; margin: 0 0 6px; }}
        .trend-label {{ font-size: 12px; color: {INK_SOFT}; margin-bottom: 6px; }}
        .sparkline {{ width: 100%; height: 70px; overflow: hidden; }}
        .sparkline svg {{ width: 100%; height: 100%; }}
        @media (max-width: 900px) {{ .dashboard {{ grid-template-columns: repeat(2, 1fr); }} }}
        @media (max-width: 600px) {{ .dashboard {{ grid-template-columns: 1fr; }} }}
    </style>
</head>
<body>
    <h1>dashboard-metrics-tiles · python · pygal · anyplot.ai</h1>
    <div class="dashboard">
"""
]

for metric in metrics:
    change = metric["change"]
    favorable = (change < 0) if metric["lower_is_better"] else (change > 0)
    change_class = "change-pos" if favorable else "change-neg"
    arrow = "▲" if change > 0 else "▼"

    mini = pygal.Line(
        width=300,
        height=80,
        style=sparkline_style,
        show_legend=False,
        show_dots=False,
        show_y_labels=False,
        show_x_labels=False,
        show_y_guides=False,
        show_x_guides=False,
        margin=2,
        fill=True,
    )
    mini.add("", metric["history"])
    sp_svg = mini.render(is_unicode=True).replace('<?xml version="1.0" encoding="utf-8"?>', "")

    html_parts.append(
        f"""        <div class="tile">
            <div class="status-bar" style="background:{STATUS_COLORS[metric["status"]]}"></div>
            <div class="metric-name">{metric["name"]}</div>
            <div class="metric-value">{metric["value"]:,}{metric["unit"]}</div>
            <div class="metric-change {change_class}">{arrow} {abs(change):.1f}%</div>
            <hr class="trend-separator">
            <div class="trend-label">7-day trend</div>
            <div class="sparkline">{sp_svg}</div>
        </div>
"""
    )

html_parts.append(
    """    </div>
</body>
</html>"""
)

with open(f"plot-{THEME}.html", "w") as f:
    f.write("".join(html_parts))

Part of Real-Time Dashboard Tiles on anyplot.ai.

Other implementations