Status: Implemented

The rendering layer turns the backend-agnostic IR (Visualize.IR.Element, Visualize.IR.Path, Visualize.IR.Transform; see spec/02) into a delivery format. Two modules implement the Visualize.Backend behaviour: Visualize.Backend.SVG emits SVG markup as an iolist and Visualize.Backend.Canvas emits Canvas 2D drawing commands as JavaScript, JSON or Elixir tuples. Visualize.Backend.CanvasBinary packs the same drawing commands into a compact little-endian binary stream for the browser; Visualize.Backend.CanvasIncremental and Visualize.Incremental layer scroll-and-copy partial updates on top of it; Visualize.Backend.Hybrid splits one chart into a static SVG layer and a dynamic Canvas layer; Visualize.Benchmark measures them all. This document is the byte-level and command-level contract for every one of those outputs.

1. Scope and shared conventions

1.1 Modules covered

ModuleRole
Visualize.Backend.SVGVisualize.Backend implementation producing SVG markup
Visualize.Backend.CanvasVisualize.Backend implementation producing Canvas 2D commands
Visualize.Backend.Canvas.ArcInternal: SVG arc to Canvas ellipse (endpoint-to-centre conversion)
Visualize.Backend.CanvasBinaryBinary encoder for Canvas commands (IR and Visualize.SVG.Element input)
Visualize.Backend.CanvasBinary.OpcodesInternal: opcode byte constants
Visualize.Backend.CanvasBinary.PathEncoderInternal: the path_cubic command stream
Visualize.Backend.CanvasBinary.SVGInternal: Visualize.SVG.Element to binary
Visualize.Backend.CanvasIncrementalScroll classification, exposed regions, incremental records
Visualize.Backend.HybridStatic SVG + dynamic Canvas split
Visualize.IncrementalStateful high-level API over CanvasIncremental
Visualize.BenchmarkThroughput and payload comparison
Visualize.Render.RasterInternal: the one module that runs the resvg binary, behind Visualize.Render.to_png/2 (§9)

1.2 The backend behaviour

Visualize.Backend (spec/02) declares path_data/1, render_path/2, render_element/2, render_scene/2 and the optional wrap_root/2. Both built-in backends implement all five. Only the one-argument forms render_element/1, render_path/1 and render_scene/1 are public API; the two-argument callback implementations, path_data/1 and wrap_root/2 are @doc false and reachable only through Visualize.Render (spec/02) or the behaviour dispatch. The one-argument forms are the two-argument forms with opts = [].

Visualize.Backend.resolve/1 maps :svg to Visualize.Backend.SVG, :canvas to Visualize.Backend.Canvas, nil to the configured default (config :visualize, default_backend:, itself defaulting to Visualize.Backend.SVG) and any other atom to itself as a module.

1.3 Number formatting

Wherever this document says "formatted", a number goes through Visualize.IR.Path.format_number/1 (spec/02 §2.2; D-12, D-32): an integer as-is; a float with at most four decimals, trailing zeros and point trimmed, never in exponent notation, never -0. The SVG backend uses it for path data, transform strings and points; the Canvas backend for every numeric argument.

SVG attribute values (as opposed to path data, transform strings and points) are NOT formatted: numbers are printed with to_string/1, so a float attribute keeps its full precision and may use exponent notation (1.0e3).

2. Visualize.Backend.SVG

2.1 Functions

Every function of this module is an @impl callback of Visualize.Backend (render_element/2, render_path/2, render_scene/2, path_data/1, wrap_root/2) or a default-argument head of one, and is therefore internal: it is reached through Visualize.Render (spec/02 §3) or by a backend-agnostic caller holding the module. The contracts below describe the callbacks.

2.2 Options

The hidden two-argument forms accept a keyword list. render_element/2 ignores its options entirely.

FunctionKeyDefaultMeaning
render_path/2:style%{}Style map serialised per 2.5 onto the <path>
render_scene/2:wraptrueWhen true, wrap the rendered elements with wrap_root/2; when false return the bare list of iolists
wrap_root/2:width800width attribute of the <svg> root
wrap_root/2:height600height attribute of the <svg> root
wrap_root/2:view_box"0 0 #{width} #{height}"viewBox attribute of the <svg> root

wrap_root/2 MUST emit <svg xmlns="http://www.w3.org/2000/svg" width=".." height=".." viewBox="..">…</svg>.

2.3 Element rendering

Each IR.Element type maps to exactly one tag. "attrs" is element.attrs, "style" is style_to_attrs(element.style) (2.5), "transform" is the serialised element.transform (2.5) added as a transform attribute when the transform is non-nil and has at least one operation.

IR typeTagAttributes emittedChildren / content
:pathpathattrs, then style merged over it, d = path_data/1 of element.path, transformnone (self-closing)
:groupgstyle, then attrs merged over it, transformchildren rendered recursively
:rootsvgxmlns="http://www.w3.org/2000/svg", then attrs (view_box emitted as viewBox, 2.4); element.style and element.transform are ignoredchildren
:rect, :circle, :ellipse, :line, :imagesame nameattrs, then style merged over it, transformnone
:polyline, :polygonsame namepoints (2.6), style, transform; every other key in attrs is droppednone
:texttextattrs, style, transformelement.content as escaped text, then the children — its tspans when it has lines
:tspantspanattrselement.content as escaped text
:title, :descsame nameattrs (the id)element.content as escaped text
:defsdefsnonechildren
:clip_pathclipPathattrs verbatimchildren
:linear_gradientlinearGradientattrs verbatimchildren
:radial_gradientradialGradientattrs verbatimchildren
:stopstopattrs, stylenone
:filterfilterattrs verbatimchildren
:fe_drop_shadow, :fe_gaussian_blurfeDropShadow, feGaussianBlurattrs (std_deviation as stdDeviation, flood_opacity as flood-opacity)none

An element with a clip (spec/02 §2.1) renders as its definition followed by the element: for :inside a <clipPath id="vis-clip-<h>"> holding the shape (rendered as any element, its style dropped) and clip-path="url(#vis-clip-<h>)" on the element; for :outside a <mask id="vis-mask-<h>" maskUnits="userSpaceOnUse" x="-100000" y="-100000" width="200000" height="200000"> holding a white rectangle of that extent and the shape in black, and mask="url(#vis-mask-<h>)" on the element. <h> is the lower-case base-36 :erlang.phash2/1 of the shape, so equal shapes share a definition and different ones never collide; the definition stands beside the element, which SVG allows anywhere, so no defs need be threaded up the tree.

A path's attrs are the data attributes of spec/10 §12.2 and §14.2 — the datum a per-series path carries and the data-series every element of a series carries (D-75, D-77, D-126); before those a path had no attrs to print. Any other type raises FunctionClauseError. Tags in circle ellipse line path polygon polyline rect image use stop feDropShadow feGaussianBlur are emitted self-closing (<rect …/>) when they have neither children nor content; all other tags are emitted as <tag …>content children</tag>.

2.4 Attribute serialisation

Tags, attributes and text are rendered by the functions of Visualize.SVG.Renderer (spec/02 §5.4), so this backend and the HEEx bridge emit one markup for one IR (D-34):

  • Attributes whose value is nil MUST be omitted.
  • Each key's SVG name comes from Visualize.SVG.Renderer.attribute_name/1: underscores become hyphens (stroke_width → stroke-width) except for the camel-cased SVG attributes, which map to their spelling (view_box → viewBox, D-11).
  • Attributes MUST be emitted sorted by that SVG name.
  • Binary values are escaped: & → &amp;, " → &quot;, < → &lt;, > → &gt;. Numbers and atoms are printed with to_string/1 and Atom.to_string/1 and are not escaped.
  • Text content is escaped for &, <, > only.

2.5 Style and transform serialisation

style_to_attrs/1 drops nil values and converts atoms that have SVG spellings:

Style keyAtom valueEmitted
:fill, :stroke:none"none"
:fill, :stroke:current_color"currentColor"
:text_anchor:start, :middle, :end"start", "middle", "end"
:stroke_linecap, :stroke_linejoinany atomAtom.to_string/1
:dominant_baselineany atomAtom.to_string/1
:blendany atomdropped for :normal; else style="mix-blend-mode: <word>" — an inline style attribute, since mix-blend-mode is a CSS property and not a presentation attribute every browser honours
:effect with :effect_radius:shadow, :blurfilter="url(#vis-effect-<effect>-<radius>)", the radius 3 when absent; :none and :effect_radius alone are dropped

Every other {key, value} pair passes through unchanged and is serialised per 2.4, so stroke_width: 2 becomes stroke-width="2" and font_size: 12 becomes font-size="12".

A transform is serialised by Visualize.IR.Transform.to_string/1 (spec/02 §2.3): its operations joined by a single space, each formatted per 1.3:

OperationString
{:translate, x, y}translate(x,y)
{:rotate, a}rotate(a)
{:rotate, a, cx, cy}rotate(a,cx,cy)
{:scale, sx, sy}scale(sx,sy), or scale(s) when sx == sy
{:skew_x, a}skewX(a)
{:skew_y, a}skewY(a)
{:matrix, a, b, c, d, e, f}matrix(a,b,c,d,e,f)

2.6 Path and point data

path_data/1 is Visualize.IR.Path.to_string/1 (spec/02 §2.2; D-12): each IR.Path command joined with no separator, each as its SVG letter followed by its comma-separated formatted arguments — {:M, x, y} → Mx,y; {:L, x, y} → Lx,y; {:H, x} → Hx; {:V, y} → Vy; {:C, x1, y1, x2, y2, x, y} → Cx1,y1,x2,y2,x,y; {:S, x2, y2, x, y} → S…; {:Q, x1, y1, x, y} → Q…; {:T, x, y} → T…; {:A, rx, ry, rot, large, sweep, x, y} → Arx,ry,rot,large,sweep,x,y (the two flags printed verbatim); :Z → Z. The lower-case relative forms (:m :l :h :v :c :s :q :t :a) are emitted with the lower-case letter and the same argument layout.

points for :polyline/:polygon is a list of {x, y} tuples formatted as x,y pairs joined by single spaces; a binary points value is emitted verbatim.

2.7 Protocol implementations

This module defines String.Chars for Visualize.IR.Element (to_string/1 = render_element/1 flattened to a binary) and, only when Phoenix.HTML.Safe is loaded at compile time, Phoenix.HTML.Safe for Visualize.IR.Element (to_iodata/1 = render_element/1). See spec/10 for how this interacts with HEEx.

3. Visualize.Backend.Canvas

3.1 Functions

Every function of this module is an @impl callback of Visualize.Backend (render_element/2, render_path/2, render_scene/2, path_data/1) or a default-argument head of one, and is therefore internal: it is reached through Visualize.Render (spec/02 §3) or by a backend-agnostic caller holding the module. The contracts below describe the callbacks.

3.2 Options and output formats

FunctionKeyDefaultAccepted values
render_element/2:format:js:js, :json, :commands, :binary, :binary_base64
render_path/2:format:js:js, :json, :commands
render_path/2:style%{}Style map (3.3.3)
render_scene/2:format:js:js, :json, :commands, :binary, :binary_base64
wrap_root/2:width800Canvas element width
wrap_root/2:height600Canvas element height
wrap_root/2:canvas_id"visualize-canvas"id of the emitted <canvas>

Output per format:

  • :commands — the Elixir list of {name :: atom, args :: list} tuples of 3.3.
  • :js — an iolist of JavaScript statements against a variable named ctx, one per command, separated by \n (3.4). render_scene/2 additionally wraps the statements as // Generated by Visualize Canvas Backend\n(function(ctx) {\n…})(context);\n.
  • :json — an iolist forming a JSON array of {"cmd": name, "args": [...]} objects (3.5).
  • :binary — Visualize.Backend.CanvasBinary.encode_element!/1 of the element (section 4); for render_scene/2, the per-element binaries concatenated in order (D-37).
  • :binary_base64 — the :binary output passed through CanvasBinary.to_base64/1.

render_element/2 and render_scene/2 accept all five formats; render_path/2 accepts :js, :json and :commands.

3.3 Command tuples

3.3.1 Element commands

Each element produces, in order, its setup (transform commands per 3.3.4, then style setup per 3.3.3), its geometry, and its apply commands (3.3.3). "T" is transform_commands(element.transform), "S" is style_setup_commands(element.style), "A" is style_apply_commands(element.style).

IR typeCommands
:pathT, S, {:beginPath, []}, path_data/1 (3.3.2), A
any shape with a clip{:save, []}, {:beginPath, []}, the shape's geometry (a :path's path_data/1, a :rect's {:rect, …}, a :circle's {:arc, …}, a :polygon's moveTo/lineTo/closePath), then {:clip, []} for :inside — or, for :outside, the geometry preceded by {:rect, [-100000, -100000, 200000, 200000]} and followed by {:clip, ["evenodd"]} — then the element's own commands, then {:restore, []}
:rectT, S, {:beginPath, []}, {:rect, [x, y, width, height]}, A — attrs MUST contain :x, :y, :width, :height
:circleT, S, {:beginPath, []}, {:arc, [cx, cy, r, 0, 2π]}, A — attrs MUST contain :cx, :cy, :r
:ellipseT, S, {:beginPath, []}, {:ellipse, [cx, cy, rx, ry, 0, 0, 2π]}, A
:lineT, S, {:beginPath, []}, {:moveTo, [x1, y1]}, {:lineTo, [x2, y2]}, {:stroke, []} — always stroked regardless of style
:polylineT, S, {:beginPath, []}, moveTo first point then lineTo each remaining point, A; no commands for an empty or missing points list
:polygonas :polyline plus {:closePath, []} after the last lineTo
:textT, text style (3.3.3), {:fillText, [content or "", x, y]} with x/y from attrs defaulting to 0
:group{:save, []}, T, S, children's commands, {:restore, []}
:rootchildren's commands only
:imageT, {:drawImage, [href, x, y, width, height]}
:defs, :clip_path, :linear_gradient, :radial_gradient, :stop, :title, :descnothing

A :group's style setup is emitted after its transform, inside the save/restore pair, so that children without their own style inherit fill, stroke and line width through Canvas state — and the fill/stroke calls that end a child's path are decided by its effective style, its own over the groups' above it, since a state that is set but never applied draws nothing (#231); Visualize.Backend.Hybrid relies on this when it wraps a list of dynamic elements in a styled group (6.3) (D-37).

3.3.2 Path commands

path_data/1 first normalises the path with Visualize.IR.Path.absolute/1 (spec/02 §2.2) — relative commands resolved against the current point, H/V as L, S/T expanded — and then maps each absolute command to one Canvas call, tracking the current point and the subpath start (D-37):

Normalised commandTuple
{:M, x, y}{:moveTo, [x, y]}
{:L, x, y}{:lineTo, [x, y]}
{:C, x1, y1, x2, y2, x, y}{:bezierCurveTo, [x1, y1, x2, y2, x, y]}
{:Q, x1, y1, x, y}{:quadraticCurveTo, [x1, y1, x, y]}
{:A, rx, ry, rot, large, sweep, x, y}{:ellipse, [cx, cy, rx', ry', rot·π/180, θ₁, θ₁ + Δθ, sweep == 0]} by the SVG endpoint-to-centre conversion (Visualize.Backend.Canvas.Arc): radii too small for the chord scale up to fit, large and sweep choose the centre and the direction, Δθ is adjusted by a full turn to match sweep; nothing when the arc's end point equals the current point; {:lineTo, [x, y]} when a radius is 0
:Z{:closePath, []}; the current point returns to the subpath start

Every tuple names a Canvas 2D method, so the :commands, :json and :js outputs are all directly executable.

3.3.3 Style commands

style_setup_commands/1 (empty map → no commands) emits, in this order and only for keys present and non-nil:

Style keyCommandNotes
:fill{:set_fillStyle, [css]}omitted for :none; :current_color → "currentColor"; any other value via to_string/1
:stroke{:set_strokeStyle, [css]}same rules
:stroke_width{:set_lineWidth, [w]}
:stroke_linecap{:set_lineCap, [to_string(v)]}
:stroke_linejoin{:set_lineJoin, [to_string(v)]}
:opacity{:set_globalAlpha, [o]}
:blend{:set_globalCompositeOperation, [to_string(v)]}omitted for :normal
:effect:shadow: {:set_shadowColor, ["rgba(0,0,0,0.35)"]}, {:set_shadowBlur, [r]}, {:set_shadowOffsetX, [r / 2]}, {:set_shadowOffsetY, [r / 2]}; :blur: {:set_filter, ["blur(<r>px)"]}; r the :effect_radius, 3 when absentomitted for :none

style_apply_commands/1 emits {:fill, []} when :fill is present and not :none, then {:stroke, []} when :stroke is present and not :none.

An absent fill is not :none (#487, D-122). An element with no :fill in its own style or any group's above it fills nothing here, while the SVG backend writes no fill attribute and the SVG initial value, black, paints it: the two backends agree on :none and disagree on absence. Neither backend papers over it — the IR is the caller's, and a hand-built SVG path relies on the initial value — so a producer that must draw the same on both says :none. The declarative layer does: a mark element whose colour reading is missing carries fill: :none (spec/14 §3.4).

A :text with :tspan children (its lines, spec/14 §7.1) emits its style commands once, then one fillText per line at the text's x and at y plus the sum of the lines' dy so far, a dy in em read as that many times the style's font_size (10 when absent).

Text style (:text elements only) emits {:set_font, [font]} when any of :font_size (default 10), :font_family (default "sans-serif"), :font_weight or :font_style is set, font being the CSS shorthand [style] [weight] size family — 12px serif, italic 300 12px sans-serif, bold 12px serif — with the style and the weight present only when set; {:set_textAlign, ["left" | "center" | "right"]} for :text_anchor :start | :middle | :end; and set_fillStyle under the fill rules above. No other style keys affect Canvas output.

3.3.4 Transform commands

A nil transform or one with no operations emits nothing. Otherwise one command per operation, in order:

OperationCommand
{:translate, x, y}{:translate, [x, y]}
{:rotate, deg}{:rotate, [deg·π/180]}
{:rotate, deg, cx, cy}{:rotate_around, [deg·π/180, cx, cy]}
{:scale, sx, sy}{:scale, [sx, sy]}
{:skew_x, deg}{:transform, [1, 0, tan(deg·π/180), 1, 0, 0]}
{:skew_y, deg}{:transform, [1, tan(deg·π/180), 0, 1, 0, 0]}
{:matrix, a, b, c, d, e, f}{:transform, [a, b, c, d, e, f]}

3.4 JavaScript serialisation

Each command becomes one statement. set_* commands become property assignments (ctx.fillStyle = 'red';, ctx.lineWidth = 2;, ctx.globalAlpha = 0.5;, ctx.font = '12px serif';, ctx.textAlign = 'center';). beginPath, closePath, fill, stroke, save, restore become the parameterless call. moveTo, lineTo, bezierCurveTo, quadraticCurveTo, arc, ellipse, rect, translate, rotate, scale, transform become the same-named call with formatted (1.3) arguments separated by ,; a boolean argument prints as true/false. rotate_around becomes ctx.translate(cx, cy); ctx.rotate(a); ctx.translate(-cx, -cy);. {:clip, []} becomes ctx.clip(); and {:clip, ["evenodd"]} ctx.clip('evenodd');. fillText becomes ctx.fillText('text', x, y); with \, ' and newline escaped in the text. drawImage becomes an immediately-invoked function that creates an Image, captures ctx.getTransform(), and in onload saves the context, restores that transform, calls ctx.drawImage(img, x, y, w, h) and restores — the load is asynchronous, the draw lands where the statement ran. No command is emitted as a comment (D-37); a command tuple the serialiser does not know raises FunctionClauseError.

3.5 JSON serialisation

The output is [ + objects joined by , + ], each object exactly {"cmd":"<name>","args":[<values>]} with the command atom's name as the string. Values: binaries are quoted with \, ", newline, carriage return and tab escaped; numbers are formatted per 1.3; true/false are the JSON literals; other atoms are quoted names; anything else is quoted to_string/1. No whitespace is emitted.

3.6 Root wrapper

wrap_root/2 MUST emit, in order: <canvas id="ID" width="W" height="H"></canvas>\n, <script>\n(function() {\n var canvas = document.getElementById('ID');\n var ctx = canvas.getContext('2d');\n, the content, })();\n</script>\n.

4. Visualize.Backend.CanvasBinary

4.1 Functions

FunctionContract
Visualize.Backend.CanvasBinary.available?/0Returns true when the Nx module is loaded; binary encoding requires it.
Visualize.Backend.CanvasBinary.encode_path/1encode_path(path, []).
Visualize.Backend.CanvasBinary.encode_path/2Encodes an IR.Path to one path record (4.5); returns {:ok, binary} or {:error, :nx_not_available}. The one option is precision:, :f32 (the default) or :f64, which selects the f32 or the f64 path records (4.5).
Visualize.Backend.CanvasBinary.encode_path!/1encode_path!(path, []).
Visualize.Backend.CanvasBinary.encode_path!/2As encode_path/2 but returns the binary directly and raises RuntimeError when Nx is absent.
Visualize.Backend.CanvasBinary.encode_element/1encode_element(element, []).
Visualize.Backend.CanvasBinary.encode_element/2Encodes an IR.Element (4.4) or a Visualize.SVG.Element (4.7) tree to a binary command stream; returns {:ok, binary} or {:error, :nx_not_available}. Takes precision: as encode_path/2 does; it governs every path record in the stream.
Visualize.Backend.CanvasBinary.encode_element!/1encode_element!(element, []).
Visualize.Backend.CanvasBinary.encode_element!/2As encode_element/2 but returns the binary directly and raises RuntimeError when Nx is absent.
Visualize.Backend.CanvasBinary.to_base64/1Standard Base64 (Base.encode64/1, padded) of a binary, for embedding in an HTML attribute or a LiveView event payload.
Visualize.Backend.CanvasBinary.Decoder.decode/1The reference decoder (#270): the records of a stream in order — the path records with their commands, circles, rects, style, transform, save/restore/fill/stroke, and the 0x60 records of §5 as {:incremental, mode, dx, dy, viewport, regions} and {:full_redraw, viewport, records} with the viewport as {x, y, w, h} — written against the record layouts this section states, not against the encoder, so decode(encode(x)) is a round trip (D-36). Raises on an unknown opcode or a truncated record. It is the executing guard a consumer runs over its own streams under test; the JavaScript executor of spec/10 §12 reads the same layouts.
Visualize.Backend.CanvasBinary.Decoder.decode_path/1The one path record of a stream as a Visualize.IR.Path.

4.2 Stream model and opcodes

A stream is a concatenation of records. Every record begins with a one-byte opcode; the opcode fixes the record's layout, so the stream is self-delimiting and carries no length prefix or header. All multi-byte integers are little-endian unsigned 32-bit (u32); all coordinates are little-endian IEEE-754 binary64 (f64) except where a record states f32, which is little-endian binary32. The two path records exist in an f64 and an f32 form under different opcodes — path/path32, path_cubic/path_cubic32 — with the same layout apart from the width of every coordinate (D-73); 4.5 says which the encoder writes. Colour components are single unsigned bytes.

Opcodes (Visualize.Backend.CanvasBinary.Opcodes, internal macros so they can appear in binary patterns):

NameByteRecord
path0x01Polyline point list (4.3.1)
path_cubic0x02Full path command stream (4.3.2)
circles0x03Circle list (4.3.3)
rects0x04Rectangle list (4.3.4)
path320x05Polyline point list, f32 coordinates (4.3.8)
path_cubic320x06Full path command stream, f32 arguments (4.3.9)
cubic_run0x07A start point and a run of cubic curves, f32, no sub-opcodes (4.3.10)
style0x10Fill/stroke/width/opacity (4.3.5)
transform0x20Coordinate transform (4.3.6)
save0x30ctx.save()
restore0x31ctx.restore()
fill0x40ctx.fill() on the current path
stroke0x41ctx.stroke() on the current path

No other opcode is defined by this module. 0x60 is defined by Visualize.Backend.CanvasIncremental (section 5) and encloses streams of these records. The module's doc comment carries the same table, and test/visualize/backend/canvas_binary_test.exs holds it to Opcodes. test/support/canvas_binary_decoder.ex is a decoder written from this section, not from the encoder, and the same test file proves decode(encode(path)) over random paths (D-36).

4.3 Record layouts

4.3.1 path (0x01)

0x01  count:u32  x0:f64 y0:f64  x1:f64 y1:f64 … x(count-1):f64 y(count-1):f64

count points, 16 bytes each (8 + 4 + 16·count bytes total). The decoder MUST moveTo the first point and lineTo each subsequent one; the path is left open. Coordinates are produced by Nx.tensor(type: :f64) |> Nx.to_binary/1 and are therefore native-endian on the encoding host; on the little-endian hosts this library targets that is little-endian.

4.3.2 path_cubic (0x02)

0x02  count:u32  command₀ command₁ … command(count-1)

count MUST equal the number of command records that follow. Each command record is a one-byte sub-opcode followed by its arguments (f64 unless stated):

Sub-opcodeIR commandArgumentsBytes
0x01{:M, x, y}x y17
0x02{:L, x, y}x y17
0x03{:C, x1, y1, x2, y2, x, y}x1 y1 x2 y2 x y49
0x04{:Q, x1, y1, x, y}x1 y1 x y33
0x05:Znone1
0x06{:A, rx, ry, rot, large, sweep, x, y}rx ry rot large:u32 sweep:u32 x y49
0x07{:a, rx, ry, rot, large, sweep, dx, dy}rx ry rot large:u32 sweep:u32 dx dy49
0x08{:H, x}x9
0x09{:V, y}y9
0x0A{:h, dx}dx9
0x0B{:v, dy}dy9
0x0C{:m, dx, dy}dx dy17
0x0D{:l, dx, dy}dx dy17
0x0E{:c, dx1, dy1, dx2, dy2, dx, dy}six f6449
0x0F{:q, dx1, dy1, dx, dy}four f6433

Relative commands (0x07, 0x0A–0x0F) are stored with their relative arguments; the decoder MUST track the current point. The smooth commands S, s, T, t have no sub-opcode: the encoder rewrites them to C/c/Q/q with Visualize.IR.Path.expand_smooth/1 (spec/02 §2.2) before encoding, so count always equals the records written; a command without a sub-opcode cannot reach the record writer and would fail to match rather than encode to nothing (D-36).

4.3.3 circles (0x03)

0x03  count:u32  cx:f64 cy:f64 r:f64  … (count times)

The encoders always write count = 1; the decoder MUST accept any count, beginning one path that holds every circle as a full arc(cx, cy, r, 0, 2π) (each preceded by a moveTo to its rightmost point), so the fill/stroke records that follow draw them all.

4.3.4 rects (0x04)

0x04  count:u32  x:f64 y:f64 w:f64 h:f64  … (count times)

The encoders always write count = 1; the decoder begins one path holding every rectangle, as for circles.

4.3.5 style (0x10)

0x10  flags:u8  [fill r:u8 g:u8 b:u8 a:u8]  [stroke r g b a]  [stroke_width:f32]  [opacity:f32]

flags bits, and the optional fields that follow in exactly this order when the bit is set:

BitMaskFieldBytes
00x01fill colour RGBA4
10x02stroke colour RGBA4
20x04stroke width, little-endian f324
30x08global alpha 0–1, little-endian f324

Alpha a is 0–255. The IR encoder emits a style record whenever element.style is non-empty, even if it sets no flags; the SVG encoder (4.7) omits the record when flags would be 0. The SVG encoder never sets bit 3.

4.3.6 transform (0x20)

0x20  kind:u8  payload
kindPayloadMeaning
0x01tx:f64 ty:f64translate(tx, ty)
0x03tx:f64 ty:f64 sx:f64 sy:f64translate(tx, ty) then scale(sx, sy)
0xFFa b c d e f (f64)transform(a, b, c, d, e, f)

The IR encoder chooses 0x01 for a transform whose operation list is exactly [{:translate, x, y}], 0x03 for exactly [{:translate, x, y}, {:scale, sx, sy}], and 0xFF otherwise, folding the operations into one matrix with Visualize.IR.Transform.to_matrix/1 (spec/02 §2.3): SVG composition (M = Op₁ · Op₂ · … · Opₙ, the last operation applying to the point first) over all seven IR operations, rotate(a, cx, cy) included (D-36). The decoder MUST apply 0xFF with ctx.transform(a, b, c, d, e, f), composing onto the current transform, not setTransform.

4.3.7 save, restore, fill, stroke

Single-byte records with no payload.

4.3.8 path32 (0x05)

0x05  count:u32  x0:f32 y0:f32  x1:f32 y1:f32 … x(count-1):f32 y(count-1):f32

The path record of 4.3.1 with every coordinate a little-endian f32: count points, 8 bytes each (1 + 4 + 8·count bytes total). The decoder draws it exactly as 4.3.1 — moveTo the first point, lineTo each subsequent one, the path left open. Each coordinate is the nearest f32 to the encoder's value (<<v::little-float-32>>, round to nearest even), so the record is lossy within the bound of 4.5; there is no Nx tensor in the f32 path, the coordinates are written by the binary constructor.

4.3.9 path_cubic32 (0x06)

0x06  count:u32  command₀ command₁ … command(count-1)

The path_cubic record of 4.3.2 with every f64 argument a little-endian f32: the same sub-opcodes, the same commands, the same argument order, large and sweep still u32, and the same rules — count MUST equal the records that follow, the smooth commands are expanded first, relative commands carry relative arguments. Only the sizes change:

Sub-opcodeIR commandBytes
0x01, 0x02, 0x0C, 0x0DM, L, m, l9
0x03, 0x0EC, c25
0x04, 0x0FQ, q17
0x05Z1
0x06, 0x07A, a (rx ry rot f32, large sweep u32, x y f32)29
0x08–0x0BH, V, h, v5

4.3.10 cubic_run (0x07)

0x07  count:u32  x0:f32 y0:f32  x1:f32 y1:f32 x2:f32 y2:f32 x:f32 y:f32  … (count times)

The path M x0,y0 followed by count absolute cubic curves C x1,y1 x2,y2 x,y, every coordinate a little-endian f32 and no sub-opcode per curve: 1 + 4 + 8 + 24·count bytes, against 5 + 9 + 25·count for the same path as path_cubic32 and 5 + 9 + 49·count as path_cubic. It is the shape the cubic curve generators of spec/04 §5 emit — Visualize.Shape.Curve.monotone_x/1, monotone_y/1, cardinal/2, catmull_rom/2 and natural/1 produce one M then Cs and nothing else — so a curved line mark is one run. basis/1 is the one cubic generator that is not: it opens with an L to its first control point and closes with an L to the last point, so a basis curve stays a command stream (4.3.9), one byte per curve larger than a run. The decoder MUST begin a path, moveTo the start point and bezierCurveTo each curve in order, leaving the path open; it MUST accept any count, though the encoder writes the record only for one curve or more (4.5). There is no f64 form: under precision: :f64 the same path is a path_cubic record (D-74).

4.4 Encoding IR elements

encode_element/1 on an IR.Element writes, per type:

IR typeRecords
:pathtransform (if non-nil with ≥1 operation), style (if the style map is non-empty), the path record (4.5), then fill if the effective style's fill is set and not :none, then stroke if its stroke is set and not :none — the effective style being the element's own over the styles of the groups above it, nearest last (#231)
:circletransform, style, circles with one entry from attrs.cx/cy/r, draw records as above
:recttransform, style, rects with one entry from attrs.x/y/width/height, draw records as above
:groupsave, transform (if any), style (if the style map is non-empty — it sets the state the children draw in, scoped by the save/restore), each child's records, restore (#231)
:rooteach child's records, nothing else
any other typenothing (zero bytes)

An element's clip (spec/02 §2.1) has no record: the element is encoded as it is, unclipped, so an inside or outside stroke (spec/14 §5.6) draws single on the binary stream. The geometric types that are not paths are encoded as the path they are (#269): a :line as M x1 y1 L x2 y2; a :polyline as a move to its first point and a line to each of the rest; a :polygon the same, closed; an :ellipse as four cubic Bézier quarter-arcs about its centre with the control distance κ = 0.5522847 of each radius, closed — each with the element's style and transform as a :path gets them, through the same record and form selection (§4.5), so a :line is byte for byte the two-point Visualize.IR.Path with the same style. Elements of type :text, :title, :desc, :image, :defs, :clip_path, gradients, filters and :stop are dropped from the binary stream, and a :blend or an :effect in a style has no record. A basemap's tiles (spec/14 §5.2, #475) are :image elements and are dropped with them, and the stream has no image record (D-120): a compiled chart draws the tiles as SVG in a backdrop the page stacks beneath the canvas (spec/14 §12.3, §12.4, #476), so a canvas track sits over them with nothing in the stream to fetch, order or wait for. Text has no binary representation by design; before #269 the four geometric types above were dropped silently as well, so an axis's tick lines drew nothing on a binary canvas.

4.5 Path form selection

encode_path/2 and the :path branch of encode_element/2 choose the path record from the command list, with the smooth curves already expanded, and from the precision: option (D-36, D-73):

  • One :M followed by zero or more :L commands — a single open polyline, the case the point-list records represent without loss of shape — is path32 (0x05) under precision: :f32, the default, and path (0x01) under :f64.
  • One :M followed by one or more :C commands — a single open cubic run, what the curve generators emit — is cubic_run (0x07) under :f32 (4.3.10); under :f64 it is path_cubic (0x02), the run having no f64 form (D-74).
  • Every other command list, the empty list included, is path_cubic32 (0x06) under :f32 and path_cubic (0x02) under :f64.

The three tests are on the command list after Visualize.IR.Path.expand_smooth/1, so a run written with S after its first C is a run too; a Z, a second subpath, a relative command or a mixed run of L and C falls to the command stream, which holds it exactly.

The default is f32 because the stream carries canvas coordinates, which are pixel positions: an f32 stores a value below 2¹⁴ = 16,384 in magnitude to within 2⁻¹¹ (≈ 0.0005 px) and one below 2¹¹ = 2,048 to within 2⁻¹³ (≈ 0.0001 px) — the rounding error is at most half an ULP, and the ULP of a value in [2ⁿ, 2ⁿ⁺¹) is 2ⁿ⁻²³ — which is far under the half pixel a rasteriser resolves, while every f64 path record is twice the size for precision the canvas cannot show. That bound is the contract of the f32 records: test/visualize/backend/canvas_binary_test.exs holds it over random coordinates, and holds decode(encode(path)) to the path with every argument rounded to its nearest f32. precision: :f64 writes the records of 4.3.1 and 4.3.2 — byte for byte the stream before D-73 — for a consumer whose values are not pixels or exceed 2²⁴, where an f32 no longer holds every integer. The option governs the path records alone: circles, rects and transform keep their f64 layouts, and Visualize.Shape.LineNx.to_binary/6,7 (spec/04 §10.2) writes the f64 path record as it always has.

4.6 Colour parsing (IR encoder)

A binary style colour is parsed by Visualize.Color.parse/1 (spec/08) and written as Visualize.Color.to_rgba/1 gives it, the opacity scaled to 0–255 and rounded: every syntax that function accepts — #rgb, #rgba, #rrggbb, #rrggbbaa, rgb(), rgba() with any decimal alpha, hsl(), hsla() and the CSS colour names — resolves; a binary it cannot parse and any non-binary value (:current_color included) yields opaque black {0, 0, 0, 255} (D-36).

4.7 Encoding SVG elements (Visualize.Backend.CanvasBinary.SVG)

encode_element/1 on a Visualize.SVG.Element reads presentation attributes from attrs instead of an IR style map. Tags handled:

TagRecords
:svgchildren only
:gif attrs.transform is present: save, the parsed transform (below), children, restore; otherwise children only
:rectstyle (if any flag set), rects from x y width height (default 0, numbers or numeric strings), draw records
:circlestyle, circles from cx cy r, draw records
:pathstyle, the command-stream record — path_cubic32 under precision: :f32, path_cubic under :f64 (4.5) — from the parsed d string, draw records; a d that parses to no commands produces zero bytes
:linestyle, the command-stream record of [{:M, x1, y1}, {:L, x2, y2}], then an unconditional stroke
:textnothing — text is not representable
any other tagnothing

Style attributes read: fill, stroke, stroke_width or "stroke-width", opacity or fill_opacity or "fill-opacity" (fill alpha), stroke_opacity or "stroke-opacity" (stroke alpha, default 1.0). A colour is "present" unless it is nil, :none or "none". Draw records: fill if fill is present, stroke if stroke is present; if neither is present and fill is not explicitly none, a single fill (SVG's default black fill). A colour — a binary, or an atom taken by its name — is parsed by Visualize.Color.parse/1 as in 4.6, so every hex, rgb(), hsl() and named form resolves and an unparseable value is black; "none" is transparent black. The supplied opacity becomes the alpha byte (trunc(opacity · 255)), except that a syntax carrying its own alpha below 1 (rgba(), hsla(), #rgba, #rrggbbaa) uses that alpha.

The transform attribute is parsed only when it begins with translate( (one or two numbers → kind 0x01, missing y = 0) or scale( (one or two numbers → kind 0x03 with zero translation); any other string, or a compound string, emits no transform record.

The d string parser accepts the commands M m L l H h V v C c Q q A a Z z, numbers with optional sign, decimal point and exponent, comma or whitespace separators, and implicit L for bare coordinate pairs following a command. S s T t are not recognised and end the parse at that point.

5. Visualize.Backend.CanvasIncremental

5.1 Functions

FunctionContract
Visualize.Backend.CanvasIncremental.copy_beneficial?/2copy_beneficial?({dx, dy}, {width, height}) is true iff abs(dx) < 0.5·width and abs(dy) < 0.5·height.
Visualize.Backend.CanvasIncremental.scroll_mode/2Classifies a scroll offset against a canvas size as :none, :scroll_x, :scroll_y, :scroll_xy or :full_redraw (5.2).
Visualize.Backend.CanvasIncremental.exposed_regions/2Returns the rectangles {x, y, w, h} (floats) newly exposed after shifting the canvas by the offset (5.3).
Visualize.Backend.CanvasIncremental.filter_to_regions/3Keeps the data points whose {x, y} (from the accessor) lie inside any region, bounds inclusive.
Visualize.Backend.CanvasIncremental.encode_incremental/4Encodes an 0x60 incremental update record for the offset, canvas size and [{bounds, element}] list (5.5), with viewport: {x, y, w, h} the rectangle that scrolls — the whole canvas by default (D-106); raises ArgumentError for more than three regions or a zero offset.
Visualize.Backend.CanvasIncremental.encode_incremental/3encode_incremental/4 with no options: the whole canvas is the viewport.
Visualize.Backend.CanvasIncremental.encode_full_redraw/2Encodes an 0x60 record in full-redraw mode carrying the element's whole binary stream, with the canvas size as the record's viewport (5.6).
Visualize.Backend.CanvasIncremental.encode_full_redraw/1encode_full_redraw/2 with no size: a zero viewport, which a full redraw never consults.

5.2 Scroll classification

scroll_mode({dx, dy}, size) returns, in this order of precedence: :none for {0, 0} — nothing moved and nothing is exposed (D-38); :full_redraw when copy_beneficial?/2 is false; :scroll_x when dy = 0; :scroll_y when dx = 0; :scroll_xy when both are non-zero.

5.3 Exposed regions

exposed_regions({dx, dy}, {width, height}) converts all four values to floats and returns:

  • [] when dx = 0 and dy = 0;
  • one vertical strip for a horizontal scroll: {width − dx, 0, dx, height} when dx > 0 (content moved left, right edge exposed) or {0, 0, |dx|, height} when dx < 0;
  • one horizontal strip for a vertical scroll: {0, height − dy, width, dy} when dy > 0 or {0, 0, width, |dy|} when dy < 0;
  • two non-overlapping strips forming an L for a diagonal scroll: the vertical strip above, plus the horizontal strip with the corner already covered removed — {0, height − dy, width − dx, dy} (dx>0, dy>0), {|dx|, height − dy, width − |dx|, dy} (dx<0, dy>0), {0, 0, width − dx, |dy|} (dx>0, dy<0), {|dx|, 0, width − |dx|, |dy|} (dx<0, dy<0).

Regions with zero or negative width or height are removed. The function does not consult copy_beneficial?/2; a caller MUST classify with scroll_mode/2 first, because an offset beyond the canvas produces nonsensical strips. The result has 0, 1 or 2 entries, never 3.

The scroll contract (#292). A copy-shift is exact: the hook copies the previous frame's pixels by {dx, dy} and draws only the exposed strips over what it copied, so it is correct only when the picture of the new frame is the picture of the old one translated by exactly {dx, dy} — every mark that stays on the canvas keeps its shape and moves by that offset, and a strip's marks are the same function of the same data as the full render's, evaluated over the strip's columns. A caller whose marks change in any other way between frames — a phase that animates, a series that slides by a fraction of a pixel — MUST NOT use the scroll modes for it: the copied pixels are then a different picture from the one the strips join, and every strip draws a fresh segment against stale neighbours. The High-Performance page sat like that (spec/14 §12.6). The same contract binds the windowed scale itself: a uniform dx moves every reading by the same number of pixels only when the scale is a translation of its units, so a compiled chart scrolls a viewport on a :linear or :time scale and redraws one on any other kind in full (14-declarative-chart §12.5, #416).

A canvas is one plot (#432). A design of several frames draws one canvas per frame (14-declarative-chart §12.1), and the contract above is per canvas: the copy-shift moves pixels within one plot, whose size is that frame's plot area and whose offset on the page is its box. Two canvases of one design scroll independently — different windows, different dx, one at a full redraw while its neighbour scrolls — and neither reads or writes the other's pixels. What they share is the tick that produced them: one sequence number across the canvases of a frame, so the page's flow control counts a frame and not a plot.

5.4 Filtering

filter_to_regions(data, regions, accessor) returns data filtered to points {px, py} = accessor.(point) for which some region {rx, ry, rw, rh} satisfies rx ≤ px ≤ rx + rw and ry ≤ py ≤ ry + rh. Coordinates are compared in the same space the regions are expressed in (canvas pixels); the caller is responsible for applying scales before comparison.

5.5 Incremental update record (0x60)

0x60  mode:u8  dx:f32  dy:f32  vx:f32  vy:f32  vw:f32  vh:f32  region_count:u8  region₀ … region(n-1)
  • mode: 0x00 full redraw, 0x01 :scroll_x, 0x02 :scroll_y, 0x03 :scroll_xy, computed by scroll_mode/2 from the offset and the viewport's size — the viewport is what is copied, so it is what the offset is classified against. :none has no byte: a zero offset is not an incremental update and encode_incremental/4 raises ArgumentError for it (D-38).
  • dx, dy: the offset as little-endian f32.
  • vx, vy, vw, vh: the viewport — the rectangle of the canvas that scrolls — as little-endian f32 (D-106, #294). encode_incremental/4 writes its viewport: option, {0, 0, width, height} when the caller gives none.
  • region_count: length(region_elements), 0–3. encode_incremental/4 raises ArgumentError for more than three regions — exposed_regions/2 never yields more than two, and the byte is never written unchecked (D-38).
  • Each region: x:f32 y:f32 w:f32 h:f32 data_length:u32 data, where data is CanvasBinary.encode_element!/1 of the element and data_length = byte_size(data).

The decoder MUST copy the viewport's pixels alone, shifted by (−dx, −dy) and clipped to the viewport, then clear and replay each region's stream clipped to its bounds. A pixel outside the viewport is never touched by a scroll frame (D-106): the High-Performance page's plot scrolls inside a margin that holds the axes, and a copy of the whole canvas carried the plot's leftmost columns into that margin every frame, where nothing cleared them — the y axis disappeared under the smear of everything that had scrolled off the plot (#294).

5.6 Full redraw record

0x60  0x00  0.0:f32  0.0:f32  vx:f32  vy:f32  vw:f32  vh:f32  0x00  data

data is the element's whole binary stream and runs to the end of the record; no length prefix is written. The header is the one layout of 5.5 — 27 bytes in every mode, so a decoder reads one header — and the viewport is the canvas size from encode_full_redraw/2 or zeros from encode_full_redraw/1; a full redraw does not consult it. The decoder MUST clear the canvas and replay data.

6. Visualize.Backend.Hybrid

6.1 Functions

FunctionContract
Visualize.Backend.Hybrid.render/1Renders the static layer as SVG and the dynamic layer as Canvas commands from one option list, returning %{svg: String.t(), canvas: String.t(), html: String.t()}.
Visualize.Backend.Hybrid.render_static/1Renders only the static SVG layer (initial render, resize); returns the SVG string.
Visualize.Backend.Hybrid.render_dynamic/1Renders only the dynamic Canvas layer (data updates); returns the command string.

6.2 Options

KeyDefaultUsed byMeaning
:widthrequired (Keyword.fetch!)render/1, render_static/1Chart width in pixels
:heightrequiredrender/1, render_static/1Chart height in pixels
:margin%{top: 20, right: 20, bottom: 30, left: 40}allMap with :top, :right, :bottom, :left; only :left and :top are read
:static[]render/1, render_static/1An IR.Element or list of them: axes, labels, grids
:dynamicnilrender/1, render_dynamic/1nil, an IR.Path, an IR.Element, or a list of IR.Element
:dynamic_style%{stroke: "#4e79a7", fill: :none, stroke_width: 2}render/1, render_dynamic/1Style applied when :dynamic is a path or a list
:canvas_format:jsonrender/1, render_dynamic/1:js, :json or :commands, passed to Canvas.render_element/2

The doc comment on render/1 states a :js default for :canvas_format; the implemented and normative default is :json, which is what the emitted container (6.3) expects. render_dynamic/1 passes any format of 3.2 through, :binary and :binary_base64 included, for a caller that feeds the CanvasBinaryChart hook itself — the compiled chart of 14-declarative-chart §12.4 does; the container of render/1 reads :json and :js only.

6.3 Output

Static layer: the static elements are wrapped in an IR.Element group with Transform.translate(margin.left, margin.top), placed as the sole child of IR.Element.root(width, height) and rendered with Visualize.Backend.SVG.render_element/1, flattened to a string. Axes, tick labels and grid lines belong here.

Dynamic layer, by the shape of :dynamic:

  • nil → "".
  • %IR.Path{} → IR.Element.path(path, dynamic_style) with the margin translate, rendered by Canvas.render_element/2.
  • %IR.Element{} → the element with its transform replaced by the margin translate (any transform it carried is lost), rendered; :dynamic_style is ignored.
  • list → IR.Element.group(list, style: dynamic_style) with the margin translate, rendered; the group's style reaches the elements through Canvas state (3.3.1).

html: a <div class="hybrid-chart" style="position: relative; width: Wpx; height: Hpx;"> containing a <div class="hybrid-svg" style="position: absolute; top: 0; left: 0; pointer-events: none;"> with the SVG, then a <div class="hybrid-canvas" phx-hook="CanvasChart" data-commands="…" data-width="W" data-height="H" style="position: absolute; top: 0; left: 0;"> wrapping <canvas width="W" height="H" style="display: block;"></canvas>. The attribute is data-commands for :json and data-script for any other format; its value is the canvas string with ", < and > HTML-escaped. The CanvasChart hook that reads either attribute ships in Visualize.Hooks (Visualize.Hooks.Canvas, spec/10 §7; D-40).

7. Visualize.Incremental

7.1 Functions

FunctionContract
Visualize.Incremental.new/1Builds the state struct from options (7.2); raises KeyError when a required option is missing.
Visualize.Incremental.initial_render/1Returns {"canvas_incremental", payload, state} with a full-redraw payload for the current viewport.
Visualize.Incremental.render_viewport/1Builds the element for the current viewport via build_element and returns {binary, state} where binary is CanvasIncremental.encode_full_redraw/1 of it.
Visualize.Incremental.scroll/2Applies a {dx, dy} delta to the viewport and returns {"canvas_incremental", payload, new_state} with an incremental, a full-redraw or a "none" payload (7.3).
Visualize.Incremental.update_data/2Replaces state.data; the viewport and other fields are unchanged.

7.2 State and new/1

The struct %Visualize.Incremental{} has fields canvas_width, canvas_height, viewport_x, viewport_y, data, x_accessor, y_accessor, build_element, margin.

OptionDefaultField
:canvas_sizerequired{canvas_width, canvas_height} in pixels
:datarequiredthe full dataset, a list
:x_accessorrequired(point -> number) giving the point's canvas x
:y_accessorrequired(point -> number) giving the point's canvas y
:build_elementrequired(data_subset, opts_map -> IR.Element.t())
:viewport_x0initial horizontal viewport offset
:viewport_y0initial vertical viewport offset
:margin%{top: 0, right: 0, bottom: 0, left: 0}stored on the struct and handed to build_element as margin

build_element receives an options map with keys viewport_x, viewport_y, canvas_width, canvas_height, margin, plus clip_region: {x, y, w, h} when called for one exposed region. render_viewport/1 passes the entire state.data to build_element: what a full frame shows is the callback's decision, made from the viewport and margin it is given, since only it knows whether a mark needs its neighbours outside the viewport (a line does, a scatter does not) (D-38). Region builds receive state.data filtered with CanvasIncremental.filter_to_regions/3 using the two accessors, so the accessors MUST return canvas-space coordinates.

7.3 Payloads

Every public entry point returns the event name "canvas_incremental" and a payload map with exactly these keys:

KeyValue
binaryBase.encode64/1 of the 0x60 record; "" for "none"
dx, dythe applied delta, or 0 for a full redraw and for "none"
mode"full", "scroll_x", "scroll_y", "scroll_xy" or "none"

scroll/2 first advances viewport_x/viewport_y by the delta, then classifies it with scroll_mode/2: :none yields the "none" payload with nothing encoded (D-38); :full_redraw yields render_viewport/1 output as a "full" payload with dx = dy = 0; any scroll mode yields exposed_regions/2, one build_element call per region, and encode_incremental/3 — the whole canvas is the viewport, since the state's canvas is its plot. The host application pushes the tuple with push_event/3 (or skips the push on "none"); the CanvasIncrementalChart hook decodes the payload and ignores "none" (spec/10 §9).

8. Visualize.Benchmark

8.1 Functions

FunctionContract
Visualize.Benchmark.run/0run([]).
Visualize.Benchmark.run/1Times SVG, Canvas (binary base64), Hybrid, Hybrid-Incremental and Canvas-Incremental rendering of a synthetic chart and returns the result map of 8.2.
Visualize.Benchmark.stream_benchmark/0stream_benchmark([]).
Visualize.Benchmark.stream_benchmark/1Renders animated frames for a fixed wall-clock duration per backend and returns the frame-rate result map of 8.3.
Visualize.Benchmark.format_report/1Renders a run/1 result map as a multi-line text report.
Visualize.Benchmark.format_stream_report/1Renders a stream_benchmark/1 result map as a multi-line text report.

Both benchmarks encode Canvas output with format: :binary_base64 and therefore require Nx.

8.2 run/1

OptionDefaultMeaning
:data_points500points per render
:iterations100timed renders per backend, after 10 untimed warm-up renders
:chart_type:line:line, :area or :scatter
:width800chart width
:height600chart height

Result map: config: %{data_points, iterations, chart_type, dimensions: {w, h}}; svg, canvas, hybrid, hybrid_incremental, canvas_incremental, each %{avg_render_time_us, renders_per_second, avg_payload_bytes, total_time_ms}; hybrid additionally static_svg_bytes; the two incremental entries additionally scroll_delta (always 20); comparison: %{svg_vs_canvas_speed, svg_vs_hybrid_speed, svg_vs_hybrid_inc_speed, svg_vs_canvas_inc_speed, fastest, smallest_per_frame} where the ratios are SVG time divided by the other backend's time rounded to 2 places and the last two are the backend keys with the lowest average time and payload.

8.3 stream_benchmark/1

OptionDefaultMeaning
:duration_ms5000wall-clock budget per backend
:data_points200points per frame
:chart_type:line:line, :area or :scatter
:width800chart width
:height600chart height
:clockfn -> System.monotonic_time(:millisecond) endzero-arity function returning milliseconds on a monotonic clock

Result map: config: %{duration_ms, data_points, chart_type}; the same five backend keys each %{frames, fps, data_points_per_second, total_bytes, actual_duration_ms}, with static_svg_bytes added to hybrid and hybrid_incremental; comparison: %{svg_fps, canvas_fps, hybrid_fps, hybrid_inc_fps, canvas_inc_fps, hybrid_vs_svg_fps}. Each backend's loop reads the clock once at the start, then once per iteration, rendering a frame while the reading is below start + duration_ms; actual_duration_ms is the last reading minus the first, frames the number of frames rendered, and fps is frames / max(actual_duration_ms / 1000, 0.001) (D-39). Nothing but clock readings enters the elapsed time, which is what test/visualize/benchmark_test.exs checks on a fake clock.

8.4 Reports

format_report/1 and format_stream_report/1 return a single string of fixed-width sections, one per backend, followed by a comparison summary (fastest backend, payload sizes, 100-frame bandwidth and the Hybrid break-even frame count for format_report/1; FPS ranking and total bandwidth for format_stream_report/1). They read only the keys listed in 8.2 and 8.3, so a result map from another source MUST supply all of them.

9. Raster output: Visualize.Render.Raster

Visualize.Render.to_png/2 and to_png!/2 (spec/02 §4) rasterise the library's SVG through the resvg command-line tool, run as an Erlang port (spec/01 §1.7, D-121). Visualize.Render.Raster is the only module in lib/ that runs it, so the backend can be replaced in one place. Its functions are @doc false (11-public-api §2.2), and the contract below is the one to_png/2 exposes.

9.1 Input

Any other input — a group, a path, a list — is a FunctionClauseError: a PNG is a whole document and has no size without its root.

The SVG must carry literal colours. The SVG backend resolves a theme slot to a custom-property reference with the literal as its fallback (var(--vis-axis, #333), 08-utilities §7.3), which a browser reads and resvg does not: resvg paints such a value black. A chart meant for a PNG is generated with resolve: :literal (Visualize.Chart.generate(applied, root: true, resolve: :literal)). An SVG containing var(-- returns {:error, :css_references} rather than a picture in the wrong colours.

9.2 Options

Each option becomes a resvg flag (9.5):

OptionDefaultMeaningFlag
:scale1Device pixel ratio, a positive number. The PNG's width and height are the root's width and height times the scale, so scale: 2 doubles both exactly.--zoom, the scale as a float (2.0, 1.5)
:backgroundnilA CSS colour string painted under the document. nil leaves the transparent pixels transparent. A colour resvg cannot parse returns {:error, {:rasterizer, message}}.--background, omitted for nil
:font_dirs[]Directories whose fonts are loaded (9.6), a list of strings. Each MUST exist and be a directory; each is expanded to an absolute path (Path.expand/1) and passed once.--use-fonts-dir, once per directory
:system_fontstrueWhether the system fonts are loaded as well. The conformance suite passes false (D-121).--skip-system-fonts when false
:generic_familiesresvg'sThe family each CSS generic keyword names, a keyword list over :serif, :sans_serif, :monospace, :cursive and :fantasy with string values. A key not given keeps resvg's own default: serif → Times New Roman, sans-serif → Arial, monospace → Courier New, cursive → Comic Sans MS, fantasy → Impact.--serif-family, --sans-serif-family, --monospace-family, --cursive-family, --fantasy-family, for each key given
:font_family"sans-serif"The family list of text that names none, as Visualize.Theme's font_family (whose default it is). It is written on the root <svg> element (9.7).none: resvg's --font-family takes a single family name, not a list, so the library writes the list into the document instead
:timeout30_000Milliseconds the render may take, a positive integer. When it expires the render is stopped and {:error, :timeout} returned (9.5).none

A :scale that is not a positive number, a :background that is neither nil nor a string, a :font_dirs that is not a list of strings naming existing directories, a :system_fonts that is not a boolean, a :generic_families with a key outside the five or a value that is not a string, a :font_family that is not a string, or a :timeout that is not a positive integer, is an ArgumentError. Options are checked before anything else, so a bad one raises whether or not a binary is installed.

:font_dirs, :system_fonts and :generic_families are the font configuration. They are held by the host, which knows its deployment's fonts (#450's policy surface): a host on a slim image with no system fonts passes its own directory, and one whose system has no Arial maps sans-serif to a family it has. A wrong configuration fails loudly in the warnings (9.7). :timeout is the caller's intent per call; one too short fails loudly as {:error, :timeout}.

Every render also passes --resources-dir with System.tmp_dir!/0, the directory relative references resolve against. resvg reading from stdin has no directory of its own and warns without one; the SVG the library renders holds no relative reference, so nothing is read from it.

9.3 Results

{:ok, png, warnings} holds a PNG binary — the 8-byte signature <<137, 80, 78, 71, 13, 10, 26, 10>> followed by an IHDR chunk whose width and height are those of 9.2 — and the list of warnings of 9.7, [] when resvg printed none. A warning is a value the caller reads, not a log line: the picture is still returned, since an alert's chart with a label missing may be worth more than none, but it is never indistinguishable from a complete one. The errors are values (spec/11 §4):

ResultWhen
{:error, :no_rasterizer}no resvg binary is configured or on the PATH, or the configured path names no executable file (9.4)
{:error, {:rasterizer_version, found, required}}the binary's version is below the supported minimum (9.4): found what resvg --version printed, trimmed, and required the minimum, "0.45.0"
{:error, :css_references}the SVG holds a var(-- reference (9.1)
{:error, :timeout}the render did not finish within :timeout milliseconds; its OS processes were killed (9.5)
{:error, {:rasterizer, message}}resvg exited non-zero, or exited 0 without a PNG; message is its last Error: … line, for example Error: unexpected end of stream., or all the text it printed, trimmed, when it printed none

to_png!/2 returns the binary of {:ok, png, []}, and raises RuntimeError naming the reason on an error and naming the warnings when they are not empty: the bang variant returns a complete picture or none.

Why the warnings are in the success tuple (#474). The proposal asks for missing families "reported in the result's warnings", and the alternatives each lose something. A warnings: :return option would make the result's shape depend on an option, so a caller's pattern match would have to know how the call was made. A separate function (missing_families/2) leaves the plain call silent and makes a careful caller render twice. An error tuple would discard a picture the host may want. to_png/2 was added in the same unreleased version (CHANGELOG.md, Unreleased), so neither widening {:ok, png} to {:ok, png, warnings} nor narrowing the warning to {:missing_family, list} (D-121) breaks a released caller.

9.4 The binary, its version, and the seam

Finding the binary. Raster.binary/0 is the host's setting, config :visualize, :resvg, "/path/to/resvg", expanded with Path.expand/1 and accepted when it names an executable file (System.find_executable/1 of the path). When the setting is absent it is System.find_executable("resvg"), the first resvg on the PATH. It is nil when neither gives a binary. A configured path that names nothing is nil too: it is never replaced by the PATH's binary, since a host that named a binary and is silently given another has a wrong setting it cannot see. A setting that is not a string is an ArgumentError. The setting is read at every call, so a host may set it at run time (config/runtime.exs).

The version. The supported minimum is resvg 0.45.0, the version Debian bookworm and trixie package (0.45.1): the raster goldens of 12-testing-and-conformance §6 hold on 0.45.1 with no pixel outside the tolerance (D-121). Before its first render, a binary's version is read from resvg --version, whose output is the bare version (0.48.1), parsed as three integers and compared as a tuple. The result is cached in :persistent_term under {Visualize.Render.Raster, :version, path, mtime, size}, the binary file's path, modification time and size, so the version is read once per binary file and a binary replaced in place is read again. The value is three integers, written once per binary: the use :persistent_term is for. Output that is not a version refuses the binary like an old one.

Availability and the seam. Raster.available?/0 is Raster.binary() != nil, read at the call. The test suite cannot remove a binary from the host it runs on, so the absent branch is reached through one argument: Raster.to_png/3 and Raster.to_png!/3 take availability as a boolean, Visualize.Render.to_png/2 passes Raster.available?(), and a test passes false. Nothing else differs between the two branches. A host with no binary is also proved end to end, by scripts/consumer_check.exs calling to_png/2 with no setting and no resvg on its PATH (12-testing-and-conformance §4).

9.5 The port

A render is one OS process tree, started for the call and gone when it returns. The SVG goes in on stdin and the PNG comes back on stdout, so no file is written. An Erlang port cannot close its write end while still reading, so resvg, which reads stdin to end-of-file, would wait forever; a POSIX sh wrapper gives it exactly the SVG's bytes and then end-of-file:

/bin/sh -c 'n="$1"; b="$2"; shift 2; head -c "$n" | exec "$b" "$@" - -c 2>&1' sh <bytes> <resvg> <flags…>

<bytes> is the SVG's byte_size/1, <resvg> the binary of 9.4 and <flags…> those of 9.2; - reads the SVG from stdin and -c writes the PNG to stdout. The port is opened with {:spawn_executable, "/bin/sh"} and [:binary, :exit_status, :stderr_to_stdout, :hide], the SVG is written with Port.command/2, and the output is collected until {:exit_status, status}.

  • Splitting the output. resvg's stderr is merged into its stdout, and resvg prints its warnings while it parses, before it writes the PNG. So the output is split at the first PNG signature (<<0x89, "PNG", 13, 10, 26, 10>>): the text before it is warnings (9.7), and the signature and everything after it is the PNG.
  • Errors. A nonzero exit status is {:error, {:rasterizer, message}}. resvg ends a failure with an Error: … line, and when it refuses an argument (an unparsable --background) it prints its usage text first, so the message is the last line that starts Error:, trimmed; when no line does, it is the whole text, trimmed. Exit 0 with no signature in the output is the same error.
  • The scheduler. The calling process waits in a receive while resvg works in its own OS process, so no BEAM scheduler is held for the render, and a crash in resvg is an exit status, not a fault in the VM. A test holds this on a node with one normal scheduler (spec/12 §6).
  • The timeout. A timer (:erlang.start_timer/3) runs for :timeout milliseconds. When it fires first, the render's processes are killed and {:error, :timeout} is returned. Closing the port would not do it: that ends head, but resvg already holds the SVG and runs to completion, unwatched. OTP starts every port program in a new session (setsid), so the port's sh, whose OS pid Port.info(port, :os_pid) gives, leads a process group that holds head and resvg too; Raster sends that group SIGKILL with the shell's kill builtin (/bin/sh -c 'kill -s KILL -- "-$1"' sh <os_pid>), naming the group by number and never a process by name. The killed sh closes the port itself, so Raster then waits for the {:exit_status, _} that follows rather than closing a port that may already be closed. A test checks that no process of the group survives the call.
  • When the caller dies. The port is linked to the calling process, so it closes with it; resvg then finishes its render and exits on the closed pipe. The work is bounded by the one render.

Raster.run/4 (@doc false) is the port alone — a binary, its arguments, the stdin bytes and a timeout, returning {:exit, status, output} or {:timeout, os_pid} — so a test can run a stand-in program through the same wrapper and timeout.

9.6 Fonts

resvg builds its font database on every call from the flags of 9.2: the --use-fonts-dir directories, and the system's fonts unless --skip-system-fonts. That costs a few milliseconds for one directory and more for a system's fonts, against a chart render of tens to hundreds of milliseconds and about 4 ms to start the process. Nothing is cached: there is no font database in the VM, a font added to a directory is seen by the next render, and the library holds no state per font configuration.

9.7 Warnings and missing families

resvg never substitutes a family: a font-family list resolves to its first member the font database has, matched case-sensitively, or the text is not drawn, and resvg says so. Two rules of the library make that report complete and exact:

  1. The default family. Text with no font-family of its own or on an ancestor would be drawn by resvg in its own default (Times New Roman), whatever the theme says, and resvg's --font-family takes one name, not a list. So the library writes :font_family (9.2), XML-escaped, as a font-family attribute on the root <svg> element, which such text inherits, unless the root already has a font-family attribute or style property. resvg resolves a family only for text that uses it, so a root default no text reads is never reported.
  2. The generic families are resvg's, as the host configured them (9.2): sans-serif reaches for Arial unless :generic_families says otherwise.

The text before the PNG (9.5) is split into lines; each is trimmed, blank lines dropped, and a line repeated (resvg reports a list once per text element) is kept once, in first-seen order. Each line becomes a warning:

  • {:missing_family, list} for a line Warning (in <module>): No match for '<printed>' font-family., the form resvg 0.45 to 0.48 print. resvg prints the list it failed to resolve normalised: each family name in double quotes, each generic keyword bare, joined by , — font-family="Other, 'Gone', sans-serif" is printed "Other", "Gone", sans-serif, and a run of whitespace inside an unquoted name is collapsed. list is that printed list with the double quotes around each member removed: {:missing_family, "Other, Gone, sans-serif"}. The members are split on commas outside double quotes, so a name holding a comma stays one member. A quoted name that equals a generic keyword ("serif") reads the same as the keyword; the warning names the list, it does not re-resolve it.
  • {:rasterizer, line} for any other line, so nothing resvg reports is dropped.

resvg's text is not a stable interface. Its parsing is the one function Raster.warnings/1 (@doc false), and a test pins it to the output of the CI's resvg (12 §4) by rendering a missing family through the binary, not only a string.