Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Canvas Composition

Canvas is SeraPlot's composition layer: a free-form surface where you place multiple independently-built Chart objects, images, shapes and text, wire them together, and export the whole thing as one Chart. It is the tool for building dashboards, annotated stories and multi-panel figures that a single chart function can't produce on its own.

import seraplot as sp

bar = sp.grouped_bar("", labels=[...], values=[...], series_names=[...])
cv = sp.canvas(1280, 800, "#0a0a0f")
bars_ref = cv.place(bar, 60, 60, 560, 420, name="revenue")
chart = cv.build()

Every placement/drawing method accepts an optional name keyword. A named element gets a data-sp-name="..." attribute in the rendered HTML, which is what makes it addressable by every other method below (nudge, resize, style, script, group, link, refill, dev-mode dragging, ...). Naming is free and has no runtime cost — name anything you might want to touch again.


Constructing a canvas

cv = sp.canvas(width: int, height: int, bg: str = "#0a0a0f")

Placing charts and images

MethodEffect
place(chart, x, y, w, h, rotation=0, opacity=1, clip="", group="", name="")Places a Chart on the canvas at (x, y) sized w×h. Returns a chart_ref (int) used by pin/connect/attach_*.
image(src, x, y, w, h, rotation=0, opacity=1, clip="", group="", name="")Places a PNG/JPEG/GIF/WebP/SVG image. src is a local file path (read and base64-embedded), a data: URI, or an http(s):// URL.
slot(name, x, y, w, h)Reserves a named region without placing anything yet.
fill(slot_name, chart, ...)Places a chart using a previously-declared slot's geometry.
grid(x, y, w, h, rows, cols, gap_x=0, gap_y=0)Declares a rows × cols grid of slots named cell_{row}_{col} and returns the list of names in row-major order — no manual coordinate math for dashboards.
refill(name, chart) -> boolReplaces a named, already-placed chart's content in place — same position, size, rotation, style. Use this (not place/fill again) whenever you're refreshing a panel with new data.

clip accepts "circle", "diamond", "hex", "tri", "pentagon" for non-rectangular chart/image framing.

Calling place/fill again with a name that's already taken by a placed chart updates that chart in place instead of stacking a duplicate — so fill(slot, new_chart, name="panel") is safe to call repeatedly.


Micro-tools: shared conventions

text, line, curve, connector, circle, ring, rect, polygon, path, arrow, annotate and gradient are the low-level drawing primitives everything else in Canvas (including voronoi(), below) is built from. They draw directly on the canvas SVG, and share two keywords:

  • layer="fg"|"bg""bg" renders under every place()d chart/image, "fg" (the default) renders on top. Use "bg" for backdrops, card panels and watermark-style decoration; "fg" for annotations, callouts and anything that should sit above the data.
  • name="" — makes the element addressable afterward by nudge, resize, style, script, group, link, and draggable in dev() mode. Naming is free; name anything you might want to touch again.

None of them require a Chart — a canvas full of nothing but these primitives is a valid way to hand-draw a diagram SeraPlot has no dedicated chart function for (see the closing example on this page).


Lines, curves & connectors

Four ways to draw between points — pick based on how many points and how rigid the shape between them needs to be.

line — straight segment

cv.line(x1, y1, x2, y2, color="#ffffff", width=1.5, dash="", opacity=1.0,
         cap="round", layer="fg", hover_group="", name="")
ParameterTypeDefaultDescription
x1, y1, x2, y2floatrequiredEndpoints, in canvas pixels
colorstr"#ffffff"Stroke color
widthfloat1.5Stroke width
dashstr""SVG stroke-dasharray, e.g. "6,4" for a dashed line
opacityfloat1.00–1
capstr"round"Line-end style: "round", "butt", or "square"
hover_groupstr""Adds an invisible wide hit-area alongside the (often thin) visible stroke, so the line reacts to hover as part of a link() group
namestr""Addressable name

The plain straight-line primitive — axes, dividers, guide lines, or one leg of a hand-built diagram.

curve — smooth line through N points

cv.curve(points, color="#ffffff", width=1.5, opacity=1.0, tension=1.0,
          fill="none", layer="fg", name="")
ParameterTypeDefaultDescription
pointslist[[x, y]]requiredThree or more waypoints the curve passes through
tensionfloat1.0Catmull-Rom tension: 0 collapses to straight segments between points, 1 is a standard smooth spline, higher values pull the curve into more pronounced bulges past each point
fillstr"none"Fills the area under the curve when set — a hand-drawn area-chart look
(color / width / opacity / layer / name — same as line)

Unlike connector (below), which always routes between exactly two points, curve interpolates through an arbitrary polyline of waypoints. Reach for it for hand-drawn trend lines, sparkline-style decoration, or free-form organic strokes that aren't tied to a Chart's own axes:

cv.curve([[40, 300], [140, 120], [260, 260], [400, 80]],
          color="#22c55e", width=3, tension=0.8, name="trend-doodle")

connector — S-curve between two points

cv.connector(x1, y1, x2, y2, color="#ffffff", width=1.5, opacity=1.0,
              bend=0.5, layer="fg", name="")
ParameterTypeDefaultDescription
bendfloat0.5Fraction along the dominant axis (whichever of dx/dy is larger) where the bezier control points sit — 0.5 gives a symmetric S-curve; values toward 0 or 1 skew the curve's midpoint toward one end
(others — same as line)

The "flowchart wire" primitive: one cubic bezier that always produces a clean S- or L-shaped route between two points, regardless of their relative position. Use it to link two place()d panels or two named elements without hand-computing control points — connect() (under Connecting two charts, below) draws the exact same curve, but reads its endpoints from registered pins instead of raw coordinates.

arrow — directional line with an arrowhead

cv.arrow(x1, y1, x2, y2, color="#ffffff", width=1.5, head_size=4.0,
          opacity=1.0, layer="fg", name="")
ParameterTypeDefaultDescription
head_sizefloat4.0Arrowhead size in px — the marker scales with this, not with width
(others — same as line)

A line with an SVG <marker> arrowhead baked onto its end, for pointing at something rather than just connecting two things.


Shapes

Five ways to fill or stroke a region, from most constrained to most free-form.

circle / ring

cv.circle(cx, cy, r, fill="none", stroke="#ffffff", stroke_width=1.5,
           opacity=1.0, layer="fg", hover_group="", name="")
cv.ring(cx, cy, inner_r, outer_r, fill="#ffffff", stroke="none",
         stroke_width=0.0, opacity=1.0, layer="fg", name="")

ring is a donut: the filled region strictly between inner_r and outer_r, built from two arcs combined with fill-rule="evenodd" rather than a solid <circle>. Use it for radial progress rings, avatar frames, or halo highlights — anywhere circle's solid disc would cover whatever sits underneath it.

rect

cv.rect(x, y, w, h, fill="none", stroke="#ffffff", stroke_width=1.5,
         rx=0.0, opacity=1.0, rotation=0.0, layer="fg", name="")

rx rounds the corners; rotation (degrees) spins the rect around its own center. The two together cover card backgrounds, chips/badges, and simple category-key swatches.

polygon

cv.polygon(points, fill="none", stroke="#ffffff", stroke_width=1.5,
            opacity=1.0, layer="fg", name="")

A closed shape through an arbitrary list[[x, y]] of vertices — the primitive voronoi() itself is built from (each cell it returns is one polygon() call under the hood). Use it directly for triangular/diamond markers, custom badge shapes, or any closed region rect/circle can't express.

path

cv.path(d, fill="none", stroke="#ffffff", stroke_width=1.5, opacity=1.0,
          layer="fg", name="")

The escape hatch: d is a raw SVG path-data string ("M ... L ... A ... Z") for shapes none of the other primitives cover — logos, icons, arcs with a specific sweep, or geometry computed by your own code. This is exactly how icicle()'s "radial" variant draws its annular sectors internally: hand-built M/A/L/Z strings, no separate arc primitive needed.


Text & annotations

text

cv.text(content, x, y, size=24.0, color="#ffffff", weight="normal",
          anchor="start", rotation=0.0, letter_spacing=0.0,
          font="sans-serif", opacity=1.0, layer="fg", name="")

anchor is the SVG text-anchor ("start", "middle", "end") relative to (x, y)"middle" centers a title over a panel, "end" right-aligns a value next to an axis.

annotate — leader-line label

cv.annotate(text, ax, ay, tx, ty, color="#ffffff", size=13.0,
             line_dash="", line_width=1.0, bg="", layer="fg", name="")
ParameterTypeDefaultDescription
ax, ayfloatrequiredThe point being annotated — where the leader line starts
tx, tyfloatrequiredWhere the text itself sits — where the leader line ends
textstrrequiredSupports \n for multi-line labels
line_dashstr""Dash pattern for the leader line, e.g. "4,3"
bgstr""Background color behind the text; ""/"none" draws no box

Unlike a plain text() + line() pair, annotate() auto-routes a clean two-segment elbow between (ax, ay) and (tx, ty) (picking the elbow point from whichever axis has the larger offset) and sizes its own background box to fit the text. The tool for "this specific point, labeled, with a callout line" — a bar's peak, a scatter outlier. annotate_at() (under Connecting two charts, below) is the pin-aware version of this same primitive, for labeling a point inside a place()d chart instead of a raw canvas coordinate.


Color: gradient

cv.gradient(id, from_color, to_color, x1=0.0, y1=0.0, x2=1.0, y2=0.0)

Registers an SVG linearGradient definition — x1/y1/x2/y2 live in the 0..1 objectBoundingBox space, so (0,0)→(1,0) is left-to-right and (0,0)→(0,1) is top-to-bottom. It draws nothing by itself; call it once, then reference fill=f"url(#{id})" on any subsequent rect/circle/ polygon/path:

cv.gradient("card-glow", "#6366f1", "#0a0a0f", x1=0, y1=0, x2=0, y2=1)
cv.rect(40, 40, 300, 200, fill="url(#card-glow)", rx=18, name="card")

Composing micro-tools: a radial dial

None of the primitives above need a Chart at all — a canvas built only from them is a fully valid way to hand-draw a widget SeraPlot has no dedicated chart function for. ring only ever draws a complete annulus, so a partial-sweep progress dial needs path with a hand-computed SVG arc — exactly the "escape hatch" role described above:

import math
import seraplot as sp

def arc_path(cx, cy, r, pct):
    start = -math.pi / 2
    end = start + 2 * math.pi * pct
    x1, y1 = cx + r * math.cos(start), cy + r * math.sin(start)
    x2, y2 = cx + r * math.cos(end), cy + r * math.sin(end)
    large_arc = 1 if pct > 0.5 else 0
    return f"M {x1:.2f},{y1:.2f} A {r},{r} 0 {large_arc},1 {x2:.2f},{y2:.2f}"

cv = sp.Canvas(300, 300, bg="#0a0a0f")
cv.gradient("dial-g", "#6366f1", "#22d3ee", x1=0, y1=0, x2=1, y2=1)
cv.ring(150, 150, 100, 112, fill="#1e293b", name="track")
cv.path(arc_path(150, 150, 106, 0.72), fill="none", stroke="url(#dial-g)",
         stroke_width=12, name="progress")
cv.text("72%", 150, 158, size=34, color="#f8fafc", weight="800",
          anchor="middle", name="pct-label")
chart = cv.build()

ring draws the static background track, path draws the live progress arc on top of it (stroked with the gradient defined a line earlier), and text centers the number — three primitives from three different sections above, one small self-contained gauge.

The hand-rolled arc_path above is exactly what arc/wedge below do internally — reach for them first; path stays the escape hatch for shapes neither one covers.


Radial drawing: arc, wedge, ribbon, polar

arc, wedge, ribbon, polar and radial_gradient share one angle convention: degrees, 0° at the top, increasing clockwise — the same convention pie/donut/gauge charts already use, so radial compositions read the same way.

MethodEffect
arc(cx, cy, r, start_deg, end_deg, color="#ffffff", width=1.5, opacity=1, cap="round", layer="fg", name="")A stroked circular arc — spokes, progress rings, radial tick marks.
wedge(cx, cy, r_inner, r_outer, start_deg, end_deg, fill="#ffffff", stroke="none", stroke_width=0, opacity=1, layer="fg", name="")A filled donut segment; r_inner=0 collapses it to a pie slice. The building block for radial bar charts — one wedge per bar, r_outer mapped to the value.
ribbon(cx, cy, r, a_start, a_end, b_start, b_end, fill="#ffffff", opacity=0.7, layer="fg", name="")A curved band connecting two arc spans on the same circle through its center — chord-diagram-style links between categories.
polar(cx, cy, r, deg) -> (x, y)Pure coordinate math, no drawing — converts a radial position to (x, y) so you can place any other primitive (text, circle, line, a placed Chart) at a computed angle instead of hand-deriving trig every time.
radial_gradient(id, from_color, to_color, cx=0.5, cy=0.5, r=0.5)A radial counterpart to gradient — reference it the same way, fill="url(#id)", for glows and center-out fades.
cv = sp.canvas(600, 600, "#0a0a12")
cx, cy = 300, 300
cv.radial_gradient("glow", "#312e81", "#0a0a12", r=0.75)
cv.circle(cx, cy, 260, fill="url(#glow)", name="glow-bg")

values = [8, 15, 6, 22, 11, 18, 4, 13]
n = len(values)
for i, v in enumerate(values):
    a0 = i * 360 / n + 2
    a1 = (i + 1) * 360 / n - 2
    cv.wedge(cx, cy, 60, 60 + v * 7, a0, a1,
             fill=f"hsl({i * 360 // n}, 70%, 60%)", name=f"bar-{i}")
    lx, ly = cv.polar(cx, cy, 60 + v * 7 + 16, (a0 + a1) / 2)
    cv.text(str(v), lx, ly, size=11, color="#f8fafc", anchor="middle", name=f"lbl-{i}")

cv.ribbon(cx, cy, 58, 10, 30, 190, 210, fill="#a78bfa", opacity=0.35, name="link-a")
chart = cv.build()

This is the same shape as the radial pieces at visualcinnamon.com — a center, a set of wedges swept around it, labels placed with polar, and ribbons crossing between spans. Nothing here is a dedicated "radial bar chart" type; it's the five primitives above composed by hand, the same way the rest of Canvas works — including mixing in a placed Chart at a polar-computed position if the story calls for it.


Three more shapes built from the same handful of primitives — no new API below this line, just polar, curve, wedge and connector combined differently each time.

A spiral — each point's radius grows with its index instead of staying fixed, the technique behind timeline-as-spiral pieces like Searching for Birds:

cx, cy = 300, 300
n = 60
cv = sp.canvas(600, 600, "#0a0a12")
pts = []
for i in range(n):
    deg = i * 12
    r = 20 + i * 4.2
    pts.append(list(cv.polar(cx, cy, r, deg)))
cv.curve(pts, color="#a78bfa", width=2, tension=0.8, name="spiral")
for i in range(0, n, 4):
    x, y = pts[i]
    cv.circle(x, y, 3 + (i / n) * 5, fill="#22d3ee", name=f"pt-{i}")
chart = cv.build()

A sunburst — two rings of wedge, the outer ring's spans computed from the inner ring's proportions instead of an even split, giving a hierarchical part-of-a-part-of-a-whole read:

cx, cy = 300, 300
cv = sp.canvas(600, 600, "#0a0a12")
groups = [("Frontend", 40), ("Backend", 35), ("Data", 25)]
subgroups = {
    "Frontend": [("React", 20), ("CSS", 12), ("A11y", 8)],
    "Backend": [("API", 18), ("Auth", 10), ("Jobs", 7)],
    "Data": [("ETL", 14), ("ML", 11)],
}

total = sum(v for _, v in groups)
cursor = 0.0
for name, v in groups:
    span = v / total * 360
    cv.wedge(cx, cy, 60, 130, cursor, cursor + span - 2, fill="#6366f1", name=f"inner-{name}")
    lx, ly = cv.polar(cx, cy, 95, cursor + span / 2)
    cv.text(name, lx, ly, size=11, color="#fff", anchor="middle", name=f"inner-lbl-{name}")

    sub_total = sum(sv for _, sv in subgroups[name])
    sub_cursor = cursor
    for sname, sv in subgroups[name]:
        sub_span = sv / sub_total * span
        cv.wedge(cx, cy, 135, 200, sub_cursor, sub_cursor + sub_span - 1,
                 fill="#22d3ee", opacity=0.85, name=f"outer-{sname}")
        lx, ly = cv.polar(cx, cy, 168, sub_cursor + sub_span / 2)
        cv.text(sname, lx, ly, size=9, color="#0a0a12", anchor="middle", name=f"outer-lbl-{sname}")
        sub_cursor += sub_span
    cursor += span
chart = cv.build()

A radial network — nodes placed on a circle with polar, random pairs joined with connector's bend for a soft curve instead of a straight chord; the same "relationships between things" story ribbon tells, drawn node-and-edge instead of band-and-arc:

import random

cx, cy = 300, 300
n = 14
cv = sp.canvas(600, 600, "#0a0a12")
cv.radial_gradient("net-glow", "#1e1b4b", "#0a0a12", r=0.85)
cv.circle(cx, cy, 280, fill="url(#net-glow)", name="bg")

nodes = [cv.polar(cx, cy, 220, i * 360 / n) for i in range(n)]

edges = set()
while len(edges) < 22:
    a, b = random.sample(range(n), 2)
    edges.add((min(a, b), max(a, b)))

for a, b in edges:
    ax, ay = nodes[a]
    bx, by = nodes[b]
    cv.connector(ax, ay, bx, by, color="#4c1d95", width=1, opacity=0.5, bend=0.15, name=f"edge-{a}-{b}")

for i, (x, y) in enumerate(nodes):
    cv.circle(x, y, 8, fill="#a78bfa", stroke="#0a0a12", stroke_width=2, name=f"node-{i}")
chart = cv.build()

Real-world composition: a RéciTAC-style network

RéciTAC (Nadieh Bremer) is a d3.js/Canvas network diagram organizing a research program's projects, people, and outcomes into a central cluster flanked by two semi-circles — researchers' disciplines on one side, activities/outputs on the other — with small donut rings around each project node encoding the disciplinary mix of everyone connected to it. None of that needs a dedicated "network chart" type: it's polar for every node position, wedge stacked into a per-node donut, connector for the edges, and link so hovering a project highlights its whole neighborhood — the same primitives from every section above, composed around one idea.

import seraplot as sp

DISCIPLINES = {"Climate": "#38bdf8", "Social": "#f472b6", "Engineering": "#a3e635", "Policy": "#fbbf24"}
RESEARCHERS = [("Amara", "Climate"), ("Lian", "Climate"), ("Devi", "Social"),
               ("Noah", "Social"), ("Jamal", "Engineering"), ("Elin", "Policy")]
ACTIVITIES = ["Workshops", "Sensors", "Policy Brief", "Dataset", "Exhibition"]
PROJECTS = [
    ("Coastal Lab", [0, 1, 3, 5], [0, 1]),
    ("Urban Heat", [1, 2, 4], [1, 3]),
    ("Youth Climate", [2, 3, 5], [2, 4]),
]

cx, cy = 450, 450
cv = sp.Canvas(900, 900)
cv.radial_gradient("bg", "#141b2e", "#05070d", cx=0.5, cy=0.46, r=0.75)
cv.rect(0, 0, 900, 900, fill="url(#bg)", layer="bg")

left = [cv.polar(cx, cy, 340, 210 + i * 25) for i in range(len(RESEARCHERS))]
right = [cv.polar(cx, cy, 340, 30 + i * 25) for i in range(len(ACTIVITIES))]
proj_pos = [cv.polar(cx, cy, 150, i * 360 / len(PROJECTS)) for i in range(len(PROJECTS))]

for i, (name, disc) in enumerate(RESEARCHERS):
    x, y = left[i]
    cv.circle(x, y, 8, fill=DISCIPLINES[disc], stroke="#0b0e18", stroke_width=2, name=f"res{i}")

for i, name in enumerate(ACTIVITIES):
    x, y = right[i]
    cv.rect(x - 6, y - 6, 12, 12, fill="#e2e8f0", rotation=45, name=f"act{i}")

for pi, (name, r_idx, a_idx) in enumerate(PROJECTS):
    px, py = proj_pos[pi]
    for ri in r_idx:
        rx, ry = left[ri]
        cv.connector(rx, ry, px, py, color=DISCIPLINES[RESEARCHERS[ri][1]], width=1.2, opacity=0.35, bend=0.2)
    for ai in a_idx:
        ax, ay = right[ai]
        cv.connector(px, py, ax, ay, color="#64748b", width=1, opacity=0.3, bend=0.2)

    counts = {}
    for ri in r_idx:
        counts[RESEARCHERS[ri][1]] = counts.get(RESEARCHERS[ri][1], 0) + 1
    start = 0.0
    for d, count in counts.items():
        end = start + count / len(r_idx) * 360
        cv.wedge(px, py, 34, 42, start, end, fill=DISCIPLINES[d])
        start = end
    cv.circle(px, py, 30, fill="#111827", stroke="#f8fafc", stroke_width=2, name=f"proj{pi}")
    cv.text(name, px, py + 4, size=11, color="#f8fafc", weight="600", anchor="middle")

    members = [f"proj{pi}"] + [f"res{ri}" for ri in r_idx] + [f"act{ai}" for ai in a_idx]
    cv.link(f"cluster{pi}", members)

chart = cv.build()

The dataset above is synthetic — the point is the composition pattern, not a literal port of Nadieh's real research-program data (which comes from a private Google Sheet). Everything scales with the data: add a discipline to the dict and every donut ring picks it up automatically; add a researcher to a project's list and a new connector + donut slice appear on the next build().


Composing real charts: a mission-control dashboard

place() embeds a full Chart — not just a primitive — inside a canvas, which means canvas composition isn't limited to hand-drawn shapes: real sp.line(), sp.bar(), sp.gauge(), sp.area() panels can sit framed, connected, and annotated by the exact same primitives used everywhere else on this page. The part that actually sells "dashboard" over "four charts in boxes" is the same trick as the RéciTAC network above: a shared center every panel connects to. One glowing hub, four color-matched spokes (connector + a circle anchor at each end), each spoke tinted to match the panel it comes from via the chart-level chainable methods (palette(), gridlines(), width()/height() — see Chart Methods) applied before place():

import seraplot as sp

W, H = 1500, 1000
HX, HY, HR = 750, 500, 76

cv = sp.Canvas(W, H)
cv.radial_gradient("dashBg", "#1a2140", "#04050a", cx=0.5, cy=0.5, r=0.9)
cv.rect(0, 0, W, H, fill="url(#dashBg)", layer="bg")
cv.radial_gradient("hubGlow", "#22d3ee", "#04050a", cx=0.5, cy=0.5, r=0.5)

cv.text("Mission Control", 48, 54, size=27, color="#f8fafc", weight="800")
cv.text("Every panel wired into one live hub — sp.Canvas place() + connectors + real SeraPlot charts",
         48, 80, size=13, color="#64748b")

PALETTE = [0x6366f1, 0x22d3ee, 0xf59e0b, 0xf472b6]
HEX = [f"#{c:06x}" for c in PALETTE]
PW, PH = 600, 300
PANELS = [("trend", 60, 150, HEX[0]), ("revenue", 840, 150, HEX[1]),
          ("health", 60, 590, HEX[2]), ("incidents", 840, 590, HEX[3])]

def panel_frame(x, y, w, h, color, name):
    cv.rect(x - 16, y - 16, w + 32, h + 32, fill="#0b1022", stroke="rgba(255,255,255,.06)",
            stroke_width=1, rx=18, layer="bg", name=name)
    cv.rect(x - 16, y - 16, w + 32, 4, fill=color, rx=2, layer="bg")

for name, x, y, color in PANELS:
    panel_frame(x, y, PW, PH, color, f"panel-{name}")

trend = sp.line(labels=["Mon","Tue","Wed","Thu","Fri","Sat","Sun"],
                  values=[820, 932, 901, 934, 1290, 1330, 1320], title="Weekly Active Users",
                 ).width(PW).height(PH).palette(PALETTE).gridlines(False).background("#0b1022")

revenue = sp.bar(labels=["Core","Cloud","API","Mobile","Support"],
                   values=[420, 680, 310, 240, 150], title="Revenue by Segment",
                  ).width(PW).height(PH).palette(PALETTE).gridlines(False).background("#0b1022")

health = sp.gauge(value=87, title="System Health").width(PW).height(PH).background("#0b1022")

incidents = sp.area(labels=["Mon","Tue","Wed","Thu","Fri","Sat","Sun"], values=[5, 3, 6, 2, 4, 1, 2],
                      title="Open Incidents",
                     ).width(PW).height(PH).palette([PALETTE[3]]).gridlines(False).background("#0b1022")

cv.place(trend, 60, 150, PW, PH, name="chart-trend")
cv.place(revenue, 840, 150, PW, PH, name="chart-revenue")
cv.place(health, 60, 590, PW, PH, name="chart-health")
cv.place(incidents, 840, 590, PW, PH, name="chart-incidents")

ANCHORS = {"trend": (660, 300), "revenue": (840, 300), "health": (660, 700), "incidents": (840, 700)}
for name, x, y, color in PANELS:
    ax, ay = ANCHORS[name]
    cv.connector(ax, ay, HX, HY, color=color, width=2, opacity=0.55, bend=0.28, name=f"spoke-{name}")
    cv.circle(ax, ay, 6, fill=color, stroke="#04050a", stroke_width=2, name=f"anchor-{name}")
    cv.circle(ax, ay, 11, fill="none", stroke=color, stroke_width=1, opacity=0.4)

cv.circle(HX, HY, HR + 34, fill="url(#hubGlow)", opacity=0.35)
cv.ring(HX, HY, HR + 18, HR + 22, fill="#22d3ee", opacity=0.25)
cv.ring(HX, HY, HR + 6, HR + 9, fill="#22d3ee", opacity=0.45)

start = 0.0
for name, _, _, color in PANELS:
    end = start + 90
    cv.wedge(HX, HY, HR - 10, HR - 4, start, end, fill=color, opacity=0.85)
    start = end

cv.circle(HX, HY, HR - 14, fill="#0b1022", stroke="#f8fafc", stroke_width=2)
cv.text("87", HX, HY - 4, size=40, color="#22d3ee", weight="800", anchor="middle")
cv.text("SYSTEM SCORE", HX, HY + 22, size=10, color="#64748b", anchor="middle", letter_spacing=1.5)

cv.link("hub-cluster", ["anchor-trend", "anchor-revenue", "anchor-health", "anchor-incidents"])

cv.annotate("Trending up 61% since Monday", 250, 320, 250, 500, color="#94a3b8", size=12,
             line_dash="4,3", bg="#0b1022")
cv.annotate("5 open, 2 critical", 1050, 660, 1150, 500, color="#94a3b8", size=12,
             line_dash="4,3", bg="#0b1022")

chart = cv.build()

The hub itself is a small radial gauge in disguise — four wedge slices (one per panel, in that panel's color) inside two ring pulse tracks, built from exactly the same primitives as the "Composing micro-tools" dial and the RéciTAC donut rings above. Reusing one visual language across every worked example on this page is the actual point: primitives don't know or care whether they're drawing a progress dial, a discipline ring, or a dashboard hub.

panel_frame() draws a rounded card plus a thin color-matched top border — that accent color is the same one used for that panel's spoke and palette(), so the eye connects "this line is orange" to "this panel is orange" to "this spoke is orange" without a legend. The one easy-to-miss detail behind all of it: any decoration meant to sit behind a placed chart (background fill, panel frames) needs layer="bg" explicitly — rect()'s default layer="fg" renders on top of placed charts by design (so connectors and callouts can cross over them), which will silently hide a panel's contents if the panel itself is drawn on the foreground layer.


Organic layouts: Voronoi

voronoi(sites, x, y, w, h, fills=None, stroke=..., stroke_width=..., opacity=...) computes a bounded Voronoi diagram — one cell per site, each cell the region closer to that site than to any other — and adds every cell to the canvas as a polygon() in one call, returning their element indices for later addressing (hover groups, derive(), etc.). This is the same organic, cell-based layout technique behind editorial data-journalism pieces like Nadieh Bremer's Highly Hazardous Pesticides — no off-the-shelf "voronoi chart type" needed, just the primitive.

import random
cv = sp.Canvas(900, 540)
sites = [[random.uniform(30, 870), random.uniform(30, 510)] for _ in range(22)]
palette = ["#6366f1", "#ec4899", "#22c55e", "#f59e0b", "#06b6d4", "#8b5cf6", "#ef4444"]
fills = [palette[i % len(palette)] for i in range(len(sites))]
cv.voronoi(sites, 0, 0, 900, 540, fills=fills, stroke="#0d1117", stroke_width=2, opacity=0.88)

Cell size follows site density automatically — cluster sites tightly to shrink their cells, useful for a treemap-like "one cell per record, colored by category, sized by local density" layout without a separate packing algorithm. Implemented natively (iterative half-plane clipping against every other site, no external geometry crate) rather than pulled in as a dependency.


Custom CSS / JS

MethodEffect
style(name, css)Injects [data-sp-name="name"]{ css } into the canvas's <style>. Pass name="" to inject a raw, unscoped CSS block (e.g. @keyframes).
script(js)Appends a raw <script>js</script> before </body> — full manual control for users who want to hand-write interactivity.

Groups and inter-plot linking

Two different mechanisms, both driven by element names:

group(group_name, member_names) / move_group(group_name, dx, dy) — moves several named elements together as a rigid unit. nudge(name, dx, dy) and resize(name, dw, dh) do the same for a single element. Pins registered on a chart before a move/resize are shifted along with it automatically.

link(group_name, member_names) -> int — ties elements across different panels into one hover group: hovering any linked element (a Chart, Rect, Text or Circle) glows/pulses all the others in the same group. Returns how many of the given names were actually linkable (Line, RawPath and other pure decoration types don't currently support it).

cv.link("story", ["revenue_chart", "trend_chart", "kpi_card"])

Connecting two charts (pins)

Pins are named anchor points registered inside a placed chart's coordinate space, in canvas pixel coordinates. connect()/annotate_at() read pins to draw a line or label between (or on top of) charts.

MethodEffect
pin(chart_ref, name, local_x, local_y)Registers a pin at a chart-local pixel coordinate.
pin_frac(chart_ref, name, fx, fy)Registers a pin at a fractional position (0..1) of the chart's native size.
`pin_xy(chart_ref, name) -> (x, y)None`
attach_bar(chart_ref, values, chart_w, chart_h, ...)Auto-registers bar:{i}:top/center/bottom/left/right pins by reading the actual rendered bar rectangles.
attach_scatter(chart_ref, x_vals, y_vals, labels, chart_w, chart_h, ...)Auto-registers point:{i} (and named) pins from the data's projected positions.
connect(from_ref, from_name, to_ref, to_name, ...)Draws a curved connector between two pins, possibly on two different charts.
annotate_at(chart_ref, pin_name, text, ...)Draws a leader-line label pointing at a pin.

Pins go stale when the geometry they were computed from changes. refill() on a chart clears its pins (so you don't silently connect to coordinates that belonged to the old content) — re-pin after refilling if you still need them. nudge/resize/move_group, on the other hand, do shift existing pins automatically, since the underlying content hasn't changed.


Reusable skeletons: template & derive

skeleton = base_canvas.template()   # strip Chart/Image elements, keep everything else
dashboard = skeleton.derive()       # deep-clone a fresh instance to fill in
dashboard.fill("main", my_chart, name="panel")

template() returns a canvas with all place()d charts and image()s removed but every decorative element (cards, gradients, titles, slots, groups, custom CSS/JS) intact — the reusable "class". derive() deep-clones any canvas (templated or not) into an independent instance — the "constructor call". Build your branded skeleton once, derive() + fill() it per dataset/variant instead of repeating layout code.


Persistence

MethodEffect
save(path)Serializes the full canvas state (elements, pins, groups, slots, custom CSS/JS) to JSON.
sp.canvas_load(path) -> CanvasRebuilds a canvas from a saved JSON file.
sp.canvas_save_named(cv, name) -> strSaves under ~/.seraplot/canvas/{name}.json and updates an index.json manifest.
sp.canvas_load_named(name) -> CanvasLoads back via that manifest.
to_json() -> strThe raw JSON string, if you want to manage storage yourself.

This is what lets a generated dashboard survive closing and reopening the app: cv.save(...) once, sp.canvas_load(...) next session reconstructs an identical canvas — positions, links, styling, everything.


Interactive dev mode

cv.dev()

Renders the canvas with a floating panel: drag any named element to move it, drag the corner handle on charts/images to resize them, hover shows the element's name and its linked group (if any). The panel's Copy Python button generates the equivalent cv.nudge(...)/cv.resize(...) calls; Download JSON exports the same deltas to a file that apply_deltas_json() can replay headlessly (cv.apply_deltas_json(open(path).read())) — the route from interactive tweaking to a reproducible script.

Canvas est la couche de composition de SeraPlot : une surface libre où l'on place plusieurs Chart construits indépendamment, des images, des formes et du texte, où on les relie entre eux, puis on exporte le tout comme un seul Chart. C'est l'outil pour construire des dashboards, des histoires annotées et des figures multi-panneaux qu'une seule fonction de chart ne peut pas produire seule.

import seraplot as sp

bar = sp.grouped_bar("", labels=[...], values=[...], series_names=[...])
cv = sp.canvas(1280, 800, "#0a0a0f")
bars_ref = cv.place(bar, 60, 60, 560, 420, name="revenue")
chart = cv.build()

Chaque méthode de placement/dessin accepte un mot-clé name optionnel. Un élément nommé reçoit un attribut data-sp-name="..." dans le HTML généré, ce qui le rend adressable par toutes les autres méthodes ci-dessous (nudge, resize, style, script, group, link, refill, le glisser-déposer du mode dev, ...). Nommer est gratuit et sans coût d'exécution — nommez tout ce que vous pourriez vouloir retoucher.


Créer un canvas

cv = sp.canvas(width: int, height: int, bg: str = "#0a0a0f")

Placer des charts et des images

MéthodeEffet
place(chart, x, y, w, h, rotation=0, opacity=1, clip="", group="", name="")Place un Chart sur le canvas à (x, y) de taille w×h. Renvoie un chart_ref (int) utilisé par pin/connect/attach_*.
image(src, x, y, w, h, rotation=0, opacity=1, clip="", group="", name="")Place une image PNG/JPEG/GIF/WebP/SVG. src est un chemin de fichier local (lu et encodé en base64), une URI data:, ou une URL http(s)://.
slot(name, x, y, w, h)Réserve une région nommée sans encore rien y placer.
fill(slot_name, chart, ...)Place un chart en utilisant la géométrie d'un slot déclaré au préalable.
grid(x, y, w, h, rows, cols, gap_x=0, gap_y=0)Déclare une grille rows × cols de slots nommés cell_{row}_{col} et renvoie la liste des noms en ordre ligne par ligne — plus de calcul de coordonnées à la main pour un dashboard.
refill(name, chart) -> boolRemplace le contenu d'un chart nommé et déjà placé, en conservant position, taille, rotation, style. À utiliser (au lieu de rappeler place/fill) chaque fois que vous rafraîchissez un panneau avec de nouvelles données.

clip accepte "circle", "diamond", "hex", "tri", "pentagon" pour un cadrage non rectangulaire du chart/de l'image.

Rappeler place/fill avec un nom déjà pris par un chart placé met à jour ce chart en place au lieu d'empiler un doublon — fill(slot, new_chart, name="panel") peut donc être rappelé sans risque.


Micro-outils : conventions communes

text, line, curve, connector, circle, ring, rect, polygon, path, arrow, annotate et gradient sont les primitives de dessin bas niveau à partir desquelles tout le reste de Canvas (y compris voronoi(), plus bas) est construit. Elles dessinent directement sur le SVG du canvas, et partagent deux mots-clés :

  • layer="fg"|"bg""bg" s'affiche sous chaque chart/image place()é, "fg" (par défaut) s'affiche au-dessus. Utilisez "bg" pour les fonds, panneaux-cartes et décorations façon filigrane ; "fg" pour les annotations, callouts et tout ce qui doit rester au-dessus des données.
  • name="" — rend l'élément adressable ensuite par nudge, resize, style, script, group, link, et déplaçable au glisser-déposer en mode dev(). Nommer est gratuit ; nommez tout ce que vous pourriez vouloir retoucher.

Aucune de ces primitives ne nécessite un Chart — un canvas ne contenant que ces primitives est une façon parfaitement valide de dessiner à la main un diagramme pour lequel SeraPlot n'a pas de fonction de chart dédiée (voir l'exemple de clôture de cette page).


Lignes, courbes & connecteurs

Quatre façons de dessiner entre des points — le choix dépend du nombre de points et de la rigidité voulue pour la forme qui les relie.

line — segment droit

cv.line(x1, y1, x2, y2, color="#ffffff", width=1.5, dash="", opacity=1.0,
         cap="round", layer="fg", hover_group="", name="")
ParamètreTypeDéfautDescription
x1, y1, x2, y2floatrequisExtrémités, en pixels canvas
colorstr"#ffffff"Couleur du trait
widthfloat1.5Épaisseur du trait
dashstr""stroke-dasharray SVG, ex. "6,4" pour un trait pointillé
opacityfloat1.00–1
capstr"round"Style d'extrémité : "round", "butt", ou "square"
hover_groupstr""Ajoute une zone de survol invisible plus large que le trait visible (souvent fin), pour que la ligne réagisse au survol en tant que membre d'un groupe link()
namestr""Nom adressable

La primitive ligne droite la plus simple — axes, séparateurs, lignes guides, ou un segment d'un diagramme construit à la main.

curve — ligne lissée passant par N points

cv.curve(points, color="#ffffff", width=1.5, opacity=1.0, tension=1.0,
          fill="none", layer="fg", name="")
ParamètreTypeDéfautDescription
pointslist[[x, y]]requisTrois points ou plus par lesquels la courbe passe
tensionfloat1.0Tension Catmull-Rom : 0 réduit la courbe à des segments droits entre les points, 1 donne une spline lissée standard, des valeurs plus hautes accentuent les bombements après chaque point
fillstr"none"Remplit la zone sous la courbe si défini — effet aire dessinée à la main
(color / width / opacity / layer / name — comme line)

Contrairement à connector (ci-dessous), qui relie toujours exactement deux points, curve interpole à travers une polyligne arbitraire de points de passage. À utiliser pour des lignes de tendance dessinées à la main, des décorations façon sparkline, ou des traits organiques libres non liés aux axes d'un Chart :

cv.curve([[40, 300], [140, 120], [260, 260], [400, 80]],
          color="#22c55e", width=3, tension=0.8, name="trend-doodle")

connector — courbe en S entre deux points

cv.connector(x1, y1, x2, y2, color="#ffffff", width=1.5, opacity=1.0,
              bend=0.5, layer="fg", name="")
ParamètreTypeDéfautDescription
bendfloat0.5Fraction, le long de l'axe dominant (celui de dx/dy le plus grand), où se placent les points de contrôle de la bézier — 0.5 donne une courbe en S symétrique ; des valeurs vers 0 ou 1 décalent le milieu de la courbe vers une extrémité
(autres — comme line)

La primitive « fil de flowchart » : une seule bézier cubique qui produit toujours un tracé propre en S ou en L entre deux points, quelle que soit leur position relative. À utiliser pour relier deux panneaux place()és ou deux éléments nommés sans calculer les points de contrôle à la main — connect() (sous Connecter deux charts, plus bas) trace exactement la même courbe, mais lit ses extrémités depuis des pins enregistrés plutôt que des coordonnées brutes.

arrow — ligne directionnelle avec pointe de flèche

cv.arrow(x1, y1, x2, y2, color="#ffffff", width=1.5, head_size=4.0,
          opacity=1.0, layer="fg", name="")
ParamètreTypeDéfautDescription
head_sizefloat4.0Taille de la pointe en px — le marqueur suit cette valeur, pas width
(autres — comme line)

Une line avec une pointe de flèche SVG (<marker>) ajoutée à son extrémité, pour pointer vers quelque chose plutôt que simplement relier deux points.


Formes

Cinq façons de remplir ou tracer une région, de la plus contrainte à la plus libre.

circle / ring

cv.circle(cx, cy, r, fill="none", stroke="#ffffff", stroke_width=1.5,
           opacity=1.0, layer="fg", hover_group="", name="")
cv.ring(cx, cy, inner_r, outer_r, fill="#ffffff", stroke="none",
         stroke_width=0.0, opacity=1.0, layer="fg", name="")

ring est un anneau (donut) : la région remplie strictement entre inner_r et outer_r, construite à partir de deux arcs combinés avec fill-rule="evenodd" plutôt qu'un <circle> plein. À utiliser pour des anneaux de progression radiale, des cadres d'avatar, ou des halos de mise en valeur — partout où le disque plein de circle masquerait ce qu'il y a en dessous.

rect

cv.rect(x, y, w, h, fill="none", stroke="#ffffff", stroke_width=1.5,
         rx=0.0, opacity=1.0, rotation=0.0, layer="fg", name="")

rx arrondit les coins ; rotation (en degrés) fait pivoter le rectangle autour de son propre centre. Les deux ensemble couvrent les fonds de carte, badges/puces, et échantillons de légende de catégorie.

polygon

cv.polygon(points, fill="none", stroke="#ffffff", stroke_width=1.5,
            opacity=1.0, layer="fg", name="")

Une forme fermée à travers une liste arbitraire list[[x, y]] de sommets — la primitive à partir de laquelle voronoi() elle-même est construite (chaque cellule qu'elle renvoie est un appel polygon() en coulisses). À utiliser directement pour des marqueurs triangulaires/en losange, des formes de badge personnalisées, ou toute région fermée que rect/circle ne peuvent pas exprimer.

path

cv.path(d, fill="none", stroke="#ffffff", stroke_width=1.5, opacity=1.0,
          layer="fg", name="")

L'échappatoire : d est une chaîne de données de chemin SVG brute ("M ... L ... A ... Z") pour les formes qu'aucune autre primitive ne couvre — logos, icônes, arcs avec un balayage spécifique, ou géométrie calculée par votre propre code. C'est exactement ainsi que la variante "radial" d'icicle() dessine ses secteurs annulaires en interne : des chaînes M/A/L/Z construites à la main, sans primitive d'arc séparée.


Texte & annotations

text

cv.text(content, x, y, size=24.0, color="#ffffff", weight="normal",
          anchor="start", rotation=0.0, letter_spacing=0.0,
          font="sans-serif", opacity=1.0, layer="fg", name="")

anchor est le text-anchor SVG ("start", "middle", "end") relatif à (x, y)"middle" centre un titre au-dessus d'un panneau, "end" aligne une valeur à droite le long d'un axe.

annotate — étiquette avec ligne de rappel

cv.annotate(text, ax, ay, tx, ty, color="#ffffff", size=13.0,
             line_dash="", line_width=1.0, bg="", layer="fg", name="")
ParamètreTypeDéfautDescription
ax, ayfloatrequisLe point annoté — où commence la ligne de rappel
tx, tyfloatrequisOù se trouve le texte lui-même — où finit la ligne de rappel
textstrrequisSupporte \n pour des étiquettes multi-lignes
line_dashstr""Motif de tirets pour la ligne de rappel, ex. "4,3"
bgstr""Couleur de fond derrière le texte ; ""/"none" ne dessine aucun cadre

Contrairement à une paire text() + line(), annotate() route automatiquement un coude propre en deux segments entre (ax, ay) et (tx, ty) (le point de coude choisi selon l'axe ayant le plus grand écart) et dimensionne son propre cadre de fond pour s'ajuster au texte. L'outil pour « ce point précis, étiqueté, avec une ligne d'appel » — le pic d'une barre, un point aberrant sur un scatter. annotate_at() (sous Connecter deux charts, plus bas) est la version « pin-aware » de cette même primitive, pour étiqueter un point à l'intérieur d'un chart place()é plutôt qu'une coordonnée canvas brute.


Couleur : gradient

cv.gradient(id, from_color, to_color, x1=0.0, y1=0.0, x2=1.0, y2=0.0)

Enregistre une définition linearGradient SVG — x1/y1/x2/y2 vivent dans l'espace objectBoundingBox 0..1, donc (0,0)→(1,0) va de gauche à droite et (0,0)→(0,1) de haut en bas. Ne dessine rien par elle-même ; appelez-la une fois, puis référencez fill=f"url(#{id})" sur n'importe quel rect/circle/polygon/path suivant :

cv.gradient("card-glow", "#6366f1", "#0a0a0f", x1=0, y1=0, x2=0, y2=1)
cv.rect(40, 40, 300, 200, fill="url(#card-glow)", rx=18, name="card")

Composer les micro-outils : un cadran radial

Aucune des primitives ci-dessus n'a besoin d'un Chart — un canvas construit uniquement à partir d'elles est une façon parfaitement valide de dessiner à la main un widget pour lequel SeraPlot n'a pas de fonction de chart dédiée. ring ne dessine jamais qu'un anneau complet, donc un cadran de progression à balayage partiel nécessite path avec un arc SVG calculé à la main — exactement le rôle d'« échappatoire » décrit plus haut :

import math
import seraplot as sp

def arc_path(cx, cy, r, pct):
    start = -math.pi / 2
    end = start + 2 * math.pi * pct
    x1, y1 = cx + r * math.cos(start), cy + r * math.sin(start)
    x2, y2 = cx + r * math.cos(end), cy + r * math.sin(end)
    large_arc = 1 if pct > 0.5 else 0
    return f"M {x1:.2f},{y1:.2f} A {r},{r} 0 {large_arc},1 {x2:.2f},{y2:.2f}"

cv = sp.Canvas(300, 300, bg="#0a0a0f")
cv.gradient("dial-g", "#6366f1", "#22d3ee", x1=0, y1=0, x2=1, y2=1)
cv.ring(150, 150, 100, 112, fill="#1e293b", name="track")
cv.path(arc_path(150, 150, 106, 0.72), fill="none", stroke="url(#dial-g)",
         stroke_width=12, name="progress")
cv.text("72%", 150, 158, size=34, color="#f8fafc", weight="800",
          anchor="middle", name="pct-label")
chart = cv.build()

ring dessine la piste de fond statique, path dessine par-dessus l'arc de progression réel (tracé avec le gradient défini juste avant), et text centre le nombre — trois primitives issues de trois sections différentes ci-dessus, une seule jauge autonome.

Le arc_path codé à la main ci-dessus, c'est exactement ce que font arc/wedge en interne — utilisez-les en premier ; path reste l'échappatoire pour les formes qu'aucun des deux ne couvre.


Dessin radial : arc, wedge, ribbon, polar

arc, wedge, ribbon, polar et radial_gradient partagent une convention d'angle : degrés, 0° en haut, croissant dans le sens horaire — la même convention que les charts pie/donut/gauge, pour que les compositions radiales se lisent de la même façon.

MéthodeEffet
arc(cx, cy, r, start_deg, end_deg, color="#ffffff", width=1.5, opacity=1, cap="round", layer="fg", name="")Un arc de cercle tracé — rayons, anneaux de progression, graduations radiales.
wedge(cx, cy, r_inner, r_outer, start_deg, end_deg, fill="#ffffff", stroke="none", stroke_width=0, opacity=1, layer="fg", name="")Un segment d'anneau rempli ; r_inner=0 le réduit à une part de camembert. La brique de base des barres radiales — une wedge par barre, r_outer mappé sur la valeur.
ribbon(cx, cy, r, a_start, a_end, b_start, b_end, fill="#ffffff", opacity=0.7, layer="fg", name="")Une bande courbe reliant deux plages d'arc sur le même cercle en passant par son centre — liens façon chord diagram entre catégories.
polar(cx, cy, r, deg) -> (x, y)Du calcul de coordonnées pur, sans dessin — convertit une position radiale en (x, y) pour placer n'importe quelle autre primitive (text, circle, line, un Chart placé) à un angle calculé, sans refaire la trigonométrie à la main.
radial_gradient(id, from_color, to_color, cx=0.5, cy=0.5, r=0.5)Le pendant radial de gradient — se référence pareil, fill="url(#id)", pour des lueurs et fondus centre-vers-bord.
cv = sp.canvas(600, 600, "#0a0a12")
cx, cy = 300, 300
cv.radial_gradient("glow", "#312e81", "#0a0a12", r=0.75)
cv.circle(cx, cy, 260, fill="url(#glow)", name="glow-bg")

values = [8, 15, 6, 22, 11, 18, 4, 13]
n = len(values)
for i, v in enumerate(values):
    a0 = i * 360 / n + 2
    a1 = (i + 1) * 360 / n - 2
    cv.wedge(cx, cy, 60, 60 + v * 7, a0, a1,
             fill=f"hsl({i * 360 // n}, 70%, 60%)", name=f"bar-{i}")
    lx, ly = cv.polar(cx, cy, 60 + v * 7 + 16, (a0 + a1) / 2)
    cv.text(str(v), lx, ly, size=11, color="#f8fafc", anchor="middle", name=f"lbl-{i}")

cv.ribbon(cx, cy, 58, 10, 30, 190, 210, fill="#a78bfa", opacity=0.35, name="link-a")
chart = cv.build()

C'est la même construction que les pièces radiales de visualcinnamon.com — un centre, des wedges disposées tout autour, des labels placés avec polar, et des ribbons qui traversent entre les plages. Rien ici n'est un type « radial bar chart » dédié ; ce sont les cinq primitives ci-dessus composées à la main, de la même façon que le reste de Canvas — y compris en mélangeant un Chart placé à une position calculée par polar si l'histoire le demande.


Galerie radiale : spirale, sunburst, réseau

Trois formes de plus construites à partir des mêmes primitives — aucune nouvelle API en dessous de cette ligne, juste polar, curve, wedge et connector combinés différemment à chaque fois.

Une spirale — le rayon de chaque point grandit avec son index au lieu de rester fixe, la technique derrière les pièces façon timeline-en-spirale comme Searching for Birds :

cx, cy = 300, 300
n = 60
cv = sp.canvas(600, 600, "#0a0a12")
pts = []
for i in range(n):
    deg = i * 12
    r = 20 + i * 4.2
    pts.append(list(cv.polar(cx, cy, r, deg)))
cv.curve(pts, color="#a78bfa", width=2, tension=0.8, name="spiral")
for i in range(0, n, 4):
    x, y = pts[i]
    cv.circle(x, y, 3 + (i / n) * 5, fill="#22d3ee", name=f"pt-{i}")
chart = cv.build()

Un sunburst — deux anneaux de wedge, les plages de l'anneau extérieur calculées à partir des proportions de l'anneau intérieur plutôt qu'un partage égal, pour une lecture hiérarchique « partie d'une partie d'un tout » :

cx, cy = 300, 300
cv = sp.canvas(600, 600, "#0a0a12")
groups = [("Frontend", 40), ("Backend", 35), ("Data", 25)]
subgroups = {
    "Frontend": [("React", 20), ("CSS", 12), ("A11y", 8)],
    "Backend": [("API", 18), ("Auth", 10), ("Jobs", 7)],
    "Data": [("ETL", 14), ("ML", 11)],
}

total = sum(v for _, v in groups)
cursor = 0.0
for name, v in groups:
    span = v / total * 360
    cv.wedge(cx, cy, 60, 130, cursor, cursor + span - 2, fill="#6366f1", name=f"inner-{name}")
    lx, ly = cv.polar(cx, cy, 95, cursor + span / 2)
    cv.text(name, lx, ly, size=11, color="#fff", anchor="middle", name=f"inner-lbl-{name}")

    sub_total = sum(sv for _, sv in subgroups[name])
    sub_cursor = cursor
    for sname, sv in subgroups[name]:
        sub_span = sv / sub_total * span
        cv.wedge(cx, cy, 135, 200, sub_cursor, sub_cursor + sub_span - 1,
                 fill="#22d3ee", opacity=0.85, name=f"outer-{sname}")
        lx, ly = cv.polar(cx, cy, 168, sub_cursor + sub_span / 2)
        cv.text(sname, lx, ly, size=9, color="#0a0a12", anchor="middle", name=f"outer-lbl-{sname}")
        sub_cursor += sub_span
    cursor += span
chart = cv.build()

Un réseau radial — des nœuds placés sur un cercle avec polar, des paires aléatoires reliées via le bend de connector pour une courbe douce plutôt qu'une corde droite ; la même histoire de « relations entre les choses » que raconte ribbon, dessinée nœuds-et-arêtes plutôt que bandes-et-arcs :

import random

cx, cy = 300, 300
n = 14
cv = sp.canvas(600, 600, "#0a0a12")
cv.radial_gradient("net-glow", "#1e1b4b", "#0a0a12", r=0.85)
cv.circle(cx, cy, 280, fill="url(#net-glow)", name="bg")

nodes = [cv.polar(cx, cy, 220, i * 360 / n) for i in range(n)]

edges = set()
while len(edges) < 22:
    a, b = random.sample(range(n), 2)
    edges.add((min(a, b), max(a, b)))

for a, b in edges:
    ax, ay = nodes[a]
    bx, by = nodes[b]
    cv.connector(ax, ay, bx, by, color="#4c1d95", width=1, opacity=0.5, bend=0.15, name=f"edge-{a}-{b}")

for i, (x, y) in enumerate(nodes):
    cv.circle(x, y, 8, fill="#a78bfa", stroke="#0a0a12", stroke_width=2, name=f"node-{i}")
chart = cv.build()

Composition réelle : un réseau façon RéciTAC

RéciTAC (Nadieh Bremer) est un diagramme réseau d3.js/Canvas qui organise les projets, les personnes et les résultats d'un programme de recherche en un cluster central flanqué de deux demi-cercles — les disciplines des chercheurs d'un côté, les activités/résultats de l'autre — avec de petits anneaux donut autour de chaque nœud projet encodant le mix disciplinaire de tous ceux qui y sont connectés. Rien de tout cela ne nécessite un type "graphique réseau" dédié : c'est polar pour chaque position de nœud, wedge empilés en un donut par nœud, connector pour les arêtes, et link pour que survoler un projet mette en valeur tout son voisinage — les mêmes primitives que dans chaque section ci-dessus, composées autour d'une seule idée.

import seraplot as sp

DISCIPLINES = {"Climate": "#38bdf8", "Social": "#f472b6", "Engineering": "#a3e635", "Policy": "#fbbf24"}
RESEARCHERS = [("Amara", "Climate"), ("Lian", "Climate"), ("Devi", "Social"),
               ("Noah", "Social"), ("Jamal", "Engineering"), ("Elin", "Policy")]
ACTIVITIES = ["Workshops", "Sensors", "Policy Brief", "Dataset", "Exhibition"]
PROJECTS = [
    ("Coastal Lab", [0, 1, 3, 5], [0, 1]),
    ("Urban Heat", [1, 2, 4], [1, 3]),
    ("Youth Climate", [2, 3, 5], [2, 4]),
]

cx, cy = 450, 450
cv = sp.Canvas(900, 900)
cv.radial_gradient("bg", "#141b2e", "#05070d", cx=0.5, cy=0.46, r=0.75)
cv.rect(0, 0, 900, 900, fill="url(#bg)", layer="bg")

left = [cv.polar(cx, cy, 340, 210 + i * 25) for i in range(len(RESEARCHERS))]
right = [cv.polar(cx, cy, 340, 30 + i * 25) for i in range(len(ACTIVITIES))]
proj_pos = [cv.polar(cx, cy, 150, i * 360 / len(PROJECTS)) for i in range(len(PROJECTS))]

for i, (name, disc) in enumerate(RESEARCHERS):
    x, y = left[i]
    cv.circle(x, y, 8, fill=DISCIPLINES[disc], stroke="#0b0e18", stroke_width=2, name=f"res{i}")

for i, name in enumerate(ACTIVITIES):
    x, y = right[i]
    cv.rect(x - 6, y - 6, 12, 12, fill="#e2e8f0", rotation=45, name=f"act{i}")

for pi, (name, r_idx, a_idx) in enumerate(PROJECTS):
    px, py = proj_pos[pi]
    for ri in r_idx:
        rx, ry = left[ri]
        cv.connector(rx, ry, px, py, color=DISCIPLINES[RESEARCHERS[ri][1]], width=1.2, opacity=0.35, bend=0.2)
    for ai in a_idx:
        ax, ay = right[ai]
        cv.connector(px, py, ax, ay, color="#64748b", width=1, opacity=0.3, bend=0.2)

    counts = {}
    for ri in r_idx:
        counts[RESEARCHERS[ri][1]] = counts.get(RESEARCHERS[ri][1], 0) + 1
    start = 0.0
    for d, count in counts.items():
        end = start + count / len(r_idx) * 360
        cv.wedge(px, py, 34, 42, start, end, fill=DISCIPLINES[d])
        start = end
    cv.circle(px, py, 30, fill="#111827", stroke="#f8fafc", stroke_width=2, name=f"proj{pi}")
    cv.text(name, px, py + 4, size=11, color="#f8fafc", weight="600", anchor="middle")

    members = [f"proj{pi}"] + [f"res{ri}" for ri in r_idx] + [f"act{ai}" for ai in a_idx]
    cv.link(f"cluster{pi}", members)

chart = cv.build()

Le jeu de données ci-dessus est synthétique — l'intérêt est le motif de composition, pas un portage littéral des vraies données du programme de recherche de Nadieh (issues d'une feuille Google Sheets privée). Tout s'adapte aux données : ajouter une discipline au dict et chaque anneau donut la prend en compte automatiquement ; ajouter un chercheur à la liste d'un projet et un nouveau connecteur + une nouvelle part de donut apparaissent au prochain build().


Composer de vrais charts : un tableau de bord mission-control

place() intègre un Chart complet — pas seulement une primitive — dans un canvas, ce qui signifie que la composition canvas ne se limite pas aux formes dessinées à la main : de vrais panneaux sp.line(), sp.bar(), sp.gauge(), sp.area() peuvent être encadrés, connectés et annotés par les mêmes primitives que partout ailleurs sur cette page. Ce qui fait vraiment la différence entre "tableau de bord" et "quatre charts dans des boîtes", c'est la même astuce que le réseau RéciTAC ci-dessus : un centre partagé auquel chaque panneau se connecte. Un hub lumineux, 4 rayons colorés (connector + une ancre circle à chaque bout), chaque rayon teinté pour correspondre à son panneau via les méthodes chainables de niveau chart (palette(), gridlines(), width()/height() — voir Méthodes de graphique) appliquées avant place() :

import seraplot as sp

W, H = 1500, 1000
HX, HY, HR = 750, 500, 76

cv = sp.Canvas(W, H)
cv.radial_gradient("dashBg", "#1a2140", "#04050a", cx=0.5, cy=0.5, r=0.9)
cv.rect(0, 0, W, H, fill="url(#dashBg)", layer="bg")
cv.radial_gradient("hubGlow", "#22d3ee", "#04050a", cx=0.5, cy=0.5, r=0.5)

cv.text("Mission Control", 48, 54, size=27, color="#f8fafc", weight="800")
cv.text("Chaque panneau relié à un seul hub vivant — sp.Canvas place() + connecteurs + de vrais charts SeraPlot",
         48, 80, size=13, color="#64748b")

PALETTE = [0x6366f1, 0x22d3ee, 0xf59e0b, 0xf472b6]
HEX = [f"#{c:06x}" for c in PALETTE]
PW, PH = 600, 300
PANELS = [("trend", 60, 150, HEX[0]), ("revenue", 840, 150, HEX[1]),
          ("health", 60, 590, HEX[2]), ("incidents", 840, 590, HEX[3])]

def panel_frame(x, y, w, h, color, name):
    cv.rect(x - 16, y - 16, w + 32, h + 32, fill="#0b1022", stroke="rgba(255,255,255,.06)",
            stroke_width=1, rx=18, layer="bg", name=name)
    cv.rect(x - 16, y - 16, w + 32, 4, fill=color, rx=2, layer="bg")

for name, x, y, color in PANELS:
    panel_frame(x, y, PW, PH, color, f"panel-{name}")

trend = sp.line(labels=["Mon","Tue","Wed","Thu","Fri","Sat","Sun"],
                  values=[820, 932, 901, 934, 1290, 1330, 1320], title="Weekly Active Users",
                 ).width(PW).height(PH).palette(PALETTE).gridlines(False).background("#0b1022")

revenue = sp.bar(labels=["Core","Cloud","API","Mobile","Support"],
                   values=[420, 680, 310, 240, 150], title="Revenue by Segment",
                  ).width(PW).height(PH).palette(PALETTE).gridlines(False).background("#0b1022")

health = sp.gauge(value=87, title="System Health").width(PW).height(PH).background("#0b1022")

incidents = sp.area(labels=["Mon","Tue","Wed","Thu","Fri","Sat","Sun"], values=[5, 3, 6, 2, 4, 1, 2],
                      title="Open Incidents",
                     ).width(PW).height(PH).palette([PALETTE[3]]).gridlines(False).background("#0b1022")

cv.place(trend, 60, 150, PW, PH, name="chart-trend")
cv.place(revenue, 840, 150, PW, PH, name="chart-revenue")
cv.place(health, 60, 590, PW, PH, name="chart-health")
cv.place(incidents, 840, 590, PW, PH, name="chart-incidents")

ANCHORS = {"trend": (660, 300), "revenue": (840, 300), "health": (660, 700), "incidents": (840, 700)}
for name, x, y, color in PANELS:
    ax, ay = ANCHORS[name]
    cv.connector(ax, ay, HX, HY, color=color, width=2, opacity=0.55, bend=0.28, name=f"spoke-{name}")
    cv.circle(ax, ay, 6, fill=color, stroke="#04050a", stroke_width=2, name=f"anchor-{name}")
    cv.circle(ax, ay, 11, fill="none", stroke=color, stroke_width=1, opacity=0.4)

cv.circle(HX, HY, HR + 34, fill="url(#hubGlow)", opacity=0.35)
cv.ring(HX, HY, HR + 18, HR + 22, fill="#22d3ee", opacity=0.25)
cv.ring(HX, HY, HR + 6, HR + 9, fill="#22d3ee", opacity=0.45)

start = 0.0
for name, _, _, color in PANELS:
    end = start + 90
    cv.wedge(HX, HY, HR - 10, HR - 4, start, end, fill=color, opacity=0.85)
    start = end

cv.circle(HX, HY, HR - 14, fill="#0b1022", stroke="#f8fafc", stroke_width=2)
cv.text("87", HX, HY - 4, size=40, color="#22d3ee", weight="800", anchor="middle")
cv.text("SYSTEM SCORE", HX, HY + 22, size=10, color="#64748b", anchor="middle", letter_spacing=1.5)

cv.link("hub-cluster", ["anchor-trend", "anchor-revenue", "anchor-health", "anchor-incidents"])

cv.annotate("Trending up 61% since Monday", 250, 320, 250, 500, color="#94a3b8", size=12,
             line_dash="4,3", bg="#0b1022")
cv.annotate("5 open, 2 critical", 1050, 660, 1150, 500, color="#94a3b8", size=12,
             line_dash="4,3", bg="#0b1022")

chart = cv.build()

Le hub lui-même est une petite jauge radiale déguisée — 4 parts de wedge (une par panneau, dans sa couleur) à l'intérieur de deux pistes ring pulsées, construites avec exactement les mêmes primitives que le cadran "Composer les micro-outils" et les anneaux donut RéciTAC ci-dessus. Réutiliser un seul langage visuel sur chaque exemple travaillé de cette page est tout l'intérêt : les primitives ne savent pas et ne se soucient pas de dessiner un cadran de progression, un anneau de discipline, ou un hub de tableau de bord.

panel_frame() dessine une carte arrondie plus une fine bordure supérieure teintée — cette couleur d'accent est la même utilisée pour le rayon de ce panneau et son palette(), pour que l'œil relie "cette ligne est orange" à "ce panneau est orange" à "ce rayon est orange" sans légende. Le détail facile à manquer derrière tout ça : toute décoration censée se trouver derrière un chart placé (fond, cadres de panneau) a besoin de layer="bg" explicitement — le layer="fg" par défaut de rect() se dessine au-dessus des charts placés par conception (pour que connecteurs et annotations puissent les traverser), ce qui masquera silencieusement le contenu d'un panneau si celui-ci est dessiné sur la couche de premier plan.


Mises en page organiques : Voronoi

voronoi(sites, x, y, w, h, fills=None, stroke=..., stroke_width=..., opacity=...) calcule un diagramme de Voronoi borné — une cellule par site, chaque cellule étant la région plus proche de ce site que de tout autre — et ajoute chaque cellule au canvas comme un polygon() en un seul appel, en renvoyant leurs indices d'éléments pour un adressage ultérieur (groupes de survol, derive(), etc.). C'est la même technique de mise en page organique par cellules derrière des pièces éditoriales de data-journalisme comme Highly Hazardous Pesticides de Nadieh Bremer — pas besoin d'un "type de chart voronoi" tout fait, juste la primitive.

import random
cv = sp.Canvas(900, 540)
sites = [[random.uniform(30, 870), random.uniform(30, 510)] for _ in range(22)]
palette = ["#6366f1", "#ec4899", "#22c55e", "#f59e0b", "#06b6d4", "#8b5cf6", "#ef4444"]
fills = [palette[i % len(palette)] for i in range(len(sites))]
cv.voronoi(sites, 0, 0, 900, 540, fills=fills, stroke="#0d1117", stroke_width=2, opacity=0.88)

La taille des cellules suit automatiquement la densité des sites — resserrer des sites rétrécit leurs cellules, utile pour une mise en page façon treemap ("une cellule par enregistrement, colorée par catégorie, dimensionnée par densité locale") sans algorithme de packing séparé. Implémenté nativement (découpage itératif par demi-plans contre chaque autre site, aucune dépendance de géométrie externe).


CSS / JS custom

MéthodeEffet
style(name, css)Injecte [data-sp-name="name"]{ css } dans le <style> du canvas. Passer name="" pour injecter un bloc CSS brut non scopé (ex. @keyframes).
script(js)Ajoute un <script>js</script> brut avant </body> — contrôle manuel complet pour qui veut écrire son interactivité à la main.

Groupes et liaison inter-plot

Deux mécanismes distincts, tous deux pilotés par les noms d'éléments :

group(group_name, member_names) / move_group(group_name, dx, dy) — déplace plusieurs éléments nommés ensemble comme un bloc rigide. nudge(name, dx, dy) et resize(name, dw, dh) font pareil pour un seul élément. Les pins enregistrés sur un chart avant un déplacement/redimensionnement sont automatiquement décalés avec lui.

link(group_name, member_names) -> int — relie des éléments à travers des panneaux différents en un seul groupe de survol : survoler n'importe quel élément lié (Chart, Rect, Text ou Circle) fait briller/pulser tous les autres du même groupe. Renvoie le nombre de noms effectivement liables (Line, RawPath et autres types purement décoratifs ne le supportent pas encore).

cv.link("story", ["revenue_chart", "trend_chart", "kpi_card"])

Connecter deux charts (pins)

Les pins sont des points d'ancrage nommés enregistrés dans l'espace de coordonnées d'un chart placé, en coordonnées pixel du canvas. connect()/annotate_at() lisent les pins pour tracer une ligne ou une étiquette entre (ou par-dessus) des charts.

MéthodeEffet
pin(chart_ref, name, local_x, local_y)Enregistre un pin à une coordonnée pixel locale au chart.
pin_frac(chart_ref, name, fx, fy)Enregistre un pin à une position fractionnaire (0..1) de la taille native du chart.
`pin_xy(chart_ref, name) -> (x, y)None`
attach_bar(chart_ref, values, chart_w, chart_h, ...)Enregistre automatiquement les pins bar:{i}:top/center/bottom/left/right en lisant les rectangles de barres réellement rendus.
attach_scatter(chart_ref, x_vals, y_vals, labels, chart_w, chart_h, ...)Enregistre automatiquement les pins point:{i} (et nommés) à partir des positions projetées des données.
connect(from_ref, from_name, to_ref, to_name, ...)Trace un connecteur courbe entre deux pins, éventuellement sur deux charts différents.
annotate_at(chart_ref, pin_name, text, ...)Trace une étiquette avec ligne de rappel pointant vers un pin.

Les pins deviennent obsolètes quand la géométrie qui les a produits change. refill() sur un chart efface ses pins (pour ne pas connecter silencieusement vers des coordonnées appartenant à l'ancien contenu) — re-pinnez après un refill si vous en avez encore besoin. nudge/resize/ move_group, en revanche, décalent bien les pins existants automatiquement, puisque le contenu sous-jacent n'a pas changé.


Squelettes réutilisables : template & derive

skeleton = base_canvas.template()   # retire les Chart/Image, garde le reste
dashboard = skeleton.derive()       # clone profond d'une instance prête à remplir
dashboard.fill("main", my_chart, name="panel")

template() renvoie un canvas dont tous les charts place()és et images image()ées sont retirés, mais où chaque élément décoratif (cartes, dégradés, titres, slots, groupes, CSS/JS custom) reste intact — la "classe" réutilisable. derive() clone en profondeur n'importe quel canvas (templatisé ou non) en une instance indépendante — "l'instanciation". Construisez votre squelette de marque une fois, puis derive() + fill() par jeu de données/variante au lieu de répéter le code de mise en page.


Persistance

MéthodeEffet
save(path)Sérialise tout l'état du canvas (éléments, pins, groupes, slots, CSS/JS custom) en JSON.
sp.canvas_load(path) -> CanvasReconstruit un canvas depuis un fichier JSON sauvegardé.
sp.canvas_save_named(cv, name) -> strSauvegarde sous ~/.seraplot/canvas/{name}.json et met à jour un manifeste index.json.
sp.canvas_load_named(name) -> CanvasRecharge via ce manifeste.
to_json() -> strLa chaîne JSON brute, pour gérer soi-même le stockage.

C'est ce qui permet à un dashboard généré de survivre à la fermeture et à la réouverture de l'application : cv.save(...) une fois, sp.canvas_load(...) à la session suivante reconstruit un canvas identique — positions, liens, style, tout.


Mode dev interactif

cv.dev()

Rend le canvas avec un panneau flottant : glissez n'importe quel élément nommé pour le déplacer, glissez la poignée en coin des charts/images pour les redimensionner, le survol affiche le nom de l'élément et son groupe lié (le cas échéant). Le bouton Copy Python du panneau génère les appels cv.nudge(...)/cv.resize(...) équivalents ; Download JSON exporte les mêmes deltas dans un fichier que apply_deltas_json() peut rejouer sans interface (cv.apply_deltas_json(open(path).read())) — le chemin entre ajustement interactif et script reproductible.