Packages

Canvas 2D API for Elixir. Build draw operations as data, render to Phoenix LiveView, native wx windows, terminal (experimental), or your own backend.

Current section

Files

Jump to
easel lib easel.ex
Raw

lib/easel.ex

defmodule Easel do
@moduledoc """
Easel lets you build Canvas 2D drawing operations as data.
Create a canvas, pipe it through drawing functions to build up
a list of operations, then render to a backend (browser via LiveView,
native window via wx, or consume the ops list directly).
All Canvas 2D API functions are available directly on this module as
`snake_cased` versions of their JavaScript counterparts. Use `set/3`
and `call/3` for properties or methods not yet generated.
## Example
canvas =
Easel.new(300, 300)
|> Easel.set_fill_style("blue")
|> Easel.fill_rect(0, 0, 100, 100)
|> Easel.set_line_width(10)
|> Easel.stroke_rect(100, 100, 100, 100)
|> Easel.render()
The resulting `%Easel{}` struct contains an `ops` list that maps
directly to Canvas 2D API calls:
canvas.ops
#=> [["set", ["fillStyle", "blue"]], ["fillRect", [0, 0, 100, 100]], ...]
## Templates and Instances
For scenes with many similar shapes, define a template once and stamp
out instances with per-instance transforms:
canvas =
Easel.new(800, 600)
|> Easel.template(:particle, fn c ->
c |> Easel.begin_path() |> Easel.arc(0, 0, 3, 0, 6.28) |> Easel.fill()
end)
|> Easel.instances(:particle, [
%{x: 100, y: 200, fill: "red"},
%{x: 300, y: 400, fill: "blue", alpha: 0.5}
])
|> Easel.render()
The LiveView hook renders instances client-side (template ops cached,
only instance data sent per frame). For other backends, call `expand/1`
to flatten into plain ops.
## Backends
* `Easel.LiveView` - Phoenix LiveView component with colocated JS hook
* `Easel.WX` - Native desktop window via Erlang's `:wx` (wxWidgets)
* `Easel.Terminal` - Experimental terminal renderer (wx off-screen raster + ASCII)
* Custom - consume `canvas.ops` directly in your own renderer
"""
defstruct width: nil, height: nil, ops: [], rendered: false, templates: %{}, template_opts: %{}
@doc "Creates a new canvas with no dimensions set."
def new do
%Easel{}
end
@doc "Creates a new canvas with the given `width` and `height`."
def new(width, height) do
%Easel{width: width, height: height}
end
@doc """
Pushes a raw operation onto the canvas.
Operations are stored in reverse order for efficient prepend.
Call `render/1` to finalize the ops list into correct order.
"""
def push_op(%Easel{} = ctx, op) do
%{ctx | ops: [op | ctx.ops], rendered: false}
end
@doc """
Sets a Canvas 2D context property.
Easel.set(canvas, "fillStyle", "blue")
"""
def set(ctx, key, val) do
push_op(ctx, ["set", [key, val]])
end
@doc """
Calls a Canvas 2D context method with the given arguments.
Easel.call(canvas, "fillRect", [0, 0, 100, 100])
"""
def call(ctx, op, arguments) do
push_op(ctx, [op, arguments])
end
# ── Generated Canvas 2D API ──────────────────────────────────────
# Generated once from priv/easel.webidl + priv/compat.json.
# To regenerate this section, run: mix easel.regen_canvas_api
# --- BEGIN GENERATED CANVAS API ---
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/arc"
def arc(ctx, x, y, radius, start_angle, end_angle) do
call(ctx, "arc", [x, y, radius, start_angle, end_angle])
end
def arc(ctx, x, y, radius, start_angle, end_angle, anticlockwise) do
call(ctx, "arc", [x, y, radius, start_angle, end_angle, anticlockwise])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/arcTo"
def arc_to(ctx, x1, y1, x2, y2, radius) do
call(ctx, "arcTo", [x1, y1, x2, y2, radius])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/beginPath"
def begin_path(ctx) do
call(ctx, "beginPath", [])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/bezierCurveTo"
def bezier_curve_to(ctx, cp1x, cp1y, cp2x, cp2y, x, y) do
call(ctx, "bezierCurveTo", [cp1x, cp1y, cp2x, cp2y, x, y])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/clearRect"
def clear_rect(ctx, x, y, w, h) do
call(ctx, "clearRect", [x, y, w, h])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/clip"
def clip(ctx) do
call(ctx, "clip", [])
end
def clip(ctx, winding) do
call(ctx, "clip", [winding])
end
def clip(ctx, path, winding) do
call(ctx, "clip", [path, winding])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/closePath"
def close_path(ctx) do
call(ctx, "closePath", [])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/createImageData"
def create_image_data(ctx, imagedata) do
call(ctx, "createImageData", [imagedata])
end
def create_image_data(ctx, sw, sh) do
call(ctx, "createImageData", [sw, sh])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/createLinearGradient"
def create_linear_gradient(ctx, x0, y0, x1, y1) do
call(ctx, "createLinearGradient", [x0, y0, x1, y1])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/createPattern"
def create_pattern(ctx, image, repetition) do
call(ctx, "createPattern", [image, repetition])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/createRadialGradient"
def create_radial_gradient(ctx, x0, y0, r0, x1, y1, r1) do
call(ctx, "createRadialGradient", [x0, y0, r0, x1, y1, r1])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawFocusIfNeeded"
def draw_focus_if_needed(ctx, element) do
call(ctx, "drawFocusIfNeeded", [element])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage"
def draw_image(ctx, image, dx, dy) do
call(ctx, "drawImage", [image, dx, dy])
end
def draw_image(ctx, image, dx, dy, dw, dh) do
call(ctx, "drawImage", [image, dx, dy, dw, dh])
end
def draw_image(ctx, image, sx, sy, sw, sh, dx, dy, dw, dh) do
call(ctx, "drawImage", [image, sx, sy, sw, sh, dx, dy, dw, dh])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/ellipse"
def ellipse(ctx, x, y, radius_x, radius_y, rotation, start_angle, end_angle) do
call(ctx, "ellipse", [x, y, radius_x, radius_y, rotation, start_angle, end_angle])
end
def ellipse(ctx, x, y, radius_x, radius_y, rotation, start_angle, end_angle, anticlockwise) do
call(ctx, "ellipse", [
x,
y,
radius_x,
radius_y,
rotation,
start_angle,
end_angle,
anticlockwise
])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/fill"
def fill(ctx) do
call(ctx, "fill", [])
end
def fill(ctx, winding) do
call(ctx, "fill", [winding])
end
def fill(ctx, path, winding) do
call(ctx, "fill", [path, winding])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/fillRect"
def fill_rect(ctx, x, y, w, h) do
call(ctx, "fillRect", [x, y, w, h])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/fillText"
def fill_text(ctx, text, x, y) do
call(ctx, "fillText", [text, x, y])
end
def fill_text(ctx, text, x, y, max_width) do
call(ctx, "fillText", [text, x, y, max_width])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getImageData"
def get_image_data(ctx, sx, sy, sw, sh) do
call(ctx, "getImageData", [sx, sy, sw, sh])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getLineDash"
def get_line_dash(ctx) do
call(ctx, "getLineDash", [])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getTransform"
def get_transform(ctx) do
call(ctx, "getTransform", [])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/isPointInPath"
def is_point_in_path(ctx, x, y) do
call(ctx, "isPointInPath", [x, y])
end
def is_point_in_path(ctx, x, y, winding) do
call(ctx, "isPointInPath", [x, y, winding])
end
def is_point_in_path(ctx, path, x, y, winding) do
call(ctx, "isPointInPath", [path, x, y, winding])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/isPointInStroke"
def is_point_in_stroke(ctx, x, y) do
call(ctx, "isPointInStroke", [x, y])
end
def is_point_in_stroke(ctx, path, x, y) do
call(ctx, "isPointInStroke", [path, x, y])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineTo"
def line_to(ctx, x, y) do
call(ctx, "lineTo", [x, y])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/measureText"
def measure_text(ctx, text) do
call(ctx, "measureText", [text])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/moveTo"
def move_to(ctx, x, y) do
call(ctx, "moveTo", [x, y])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/putImageData"
def put_image_data(ctx, imagedata, dx, dy) do
call(ctx, "putImageData", [imagedata, dx, dy])
end
def put_image_data(ctx, imagedata, dx, dy, dirty_x, dirty_y, dirty_width, dirty_height) do
call(ctx, "putImageData", [imagedata, dx, dy, dirty_x, dirty_y, dirty_width, dirty_height])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/quadraticCurveTo"
def quadratic_curve_to(ctx, cpx, cpy, x, y) do
call(ctx, "quadraticCurveTo", [cpx, cpy, x, y])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/rect"
def rect(ctx, x, y, w, h) do
call(ctx, "rect", [x, y, w, h])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/resetTransform"
def reset_transform(ctx) do
call(ctx, "resetTransform", [])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/restore"
def restore(ctx) do
call(ctx, "restore", [])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/rotate"
def rotate(ctx, angle) do
call(ctx, "rotate", [angle])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/save"
def save(ctx) do
call(ctx, "save", [])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/scale"
def scale(ctx, x, y) do
call(ctx, "scale", [x, y])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/fillStyle"
def set_fill_style(ctx, value) do
set(ctx, "fillStyle", value)
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/filter"
def set_filter(ctx, value) do
set(ctx, "filter", value)
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/font"
def set_font(ctx, value) do
set(ctx, "font", value)
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalAlpha"
def set_global_alpha(ctx, value) do
set(ctx, "globalAlpha", value)
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation"
def set_global_composite_operation(ctx, value) do
set(ctx, "globalCompositeOperation", value)
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/imageSmoothingEnabled"
def set_image_smoothing_enabled(ctx, value) do
set(ctx, "imageSmoothingEnabled", value)
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineCap"
def set_line_cap(ctx, value) do
set(ctx, "lineCap", value)
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/setLineDash"
def set_line_dash(ctx, segments) do
call(ctx, "setLineDash", [segments])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineDashOffset"
def set_line_dash_offset(ctx, value) do
set(ctx, "lineDashOffset", value)
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineJoin"
def set_line_join(ctx, value) do
set(ctx, "lineJoin", value)
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineWidth"
def set_line_width(ctx, value) do
set(ctx, "lineWidth", value)
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/miterLimit"
def set_miter_limit(ctx, value) do
set(ctx, "miterLimit", value)
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/shadowBlur"
def set_shadow_blur(ctx, value) do
set(ctx, "shadowBlur", value)
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/shadowColor"
def set_shadow_color(ctx, value) do
set(ctx, "shadowColor", value)
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/shadowOffsetX"
def set_shadow_offset_x(ctx, value) do
set(ctx, "shadowOffsetX", value)
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/shadowOffsetY"
def set_shadow_offset_y(ctx, value) do
set(ctx, "shadowOffsetY", value)
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/strokeStyle"
def set_stroke_style(ctx, value) do
set(ctx, "strokeStyle", value)
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/textAlign"
def set_text_align(ctx, value) do
set(ctx, "textAlign", value)
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/textBaseline"
def set_text_baseline(ctx, value) do
set(ctx, "textBaseline", value)
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/setTransform"
def set_transform(ctx) do
call(ctx, "setTransform", [])
end
def set_transform(ctx, transform) do
call(ctx, "setTransform", [transform])
end
def set_transform(ctx, a, b, c, d, e, f) do
call(ctx, "setTransform", [a, b, c, d, e, f])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/stroke"
def stroke(ctx) do
call(ctx, "stroke", [])
end
def stroke(ctx, path) do
call(ctx, "stroke", [path])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/strokeRect"
def stroke_rect(ctx, x, y, w, h) do
call(ctx, "strokeRect", [x, y, w, h])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/strokeText"
def stroke_text(ctx, text, x, y) do
call(ctx, "strokeText", [text, x, y])
end
def stroke_text(ctx, text, x, y, max_width) do
call(ctx, "strokeText", [text, x, y, max_width])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/transform"
def transform(ctx, a, b, c, d, e, f) do
call(ctx, "transform", [a, b, c, d, e, f])
end
@doc "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/translate"
def translate(ctx, x, y) do
call(ctx, "translate", [x, y])
end
# --- END GENERATED CANVAS API ---
# ── Templates and Instances ──────────────────────────────────────
@doc """
Defines a reusable drawing template.
A template is a named set of ops that can be instantiated many times
with different transforms. The template function receives a fresh
`%Easel{}` and should return one with ops added (don't call `render/1`).
## Example
canvas =
Easel.new(800, 600)
|> Easel.template(:boid, fn c ->
c
|> Easel.begin_path()
|> Easel.move_to(12, 0)
|> Easel.line_to(-4, -5)
|> Easel.line_to(-4, 5)
|> Easel.close_path()
|> Easel.fill()
end)
"""
def template(%Easel{} = ctx, name, draw_fn, opts \\ [])
when is_atom(name) and is_function(draw_fn, 1) do
tpl = draw_fn.(%Easel{}) |> render()
t_opts = template_instance_opts(opts)
template_opts =
if t_opts == [], do: ctx.template_opts, else: Map.put(ctx.template_opts, name, t_opts)
%{ctx | templates: Map.put(ctx.templates, name, tpl.ops), template_opts: template_opts}
end
@doc """
Sets template-level instance options for this canvas.
Useful when templates are prepared in one canvas and instances are emitted
from another (for example, static templates cached in assigns).
"""
def with_template_opts(%Easel{} = ctx, opts_map) when is_map(opts_map) do
%{ctx | template_opts: Map.merge(ctx.template_opts, opts_map)}
end
@doc """
Draws instances of a previously defined template.
Each instance is a map that may contain:
* `:x`, `:y` - translation (default `0`, `0`)
* `:rotate` - rotation in radians (default `0`)
* `:scale_x`, `:scale_y` - scale factors (default `1`, `1`)
* `:fill` - fill style override (applied before template ops)
* `:stroke` - stroke style override
* `:alpha` - global alpha override
The hook renders each instance via `save → translate → rotate → scale →
[style overrides] → [template ops] → restore`.
You can optionally quantize float values before serialization by passing
`precision` globally or per field:
Easel.instances(canvas, :boid, instances, precision: 2)
Easel.instances(canvas, :boid, instances, x: 1, y: 1, rotate: 3)
## Example
canvas
|> Easel.instances(:boid, Enum.map(boids, fn b ->
%{x: b.x, y: b.y, rotate: :math.atan2(b.vy, b.vx),
fill: "hsl(\#{hue}, 70%, 60%)"}
end))
"""
def instances(%Easel{} = ctx, name, instance_data, opts \\ [])
when is_atom(name) and is_list(instance_data) do
merged_opts = merge_instance_opts(Map.get(ctx.template_opts, name, []), opts)
{rows, palette, cols} = encode_instances(instance_data, merged_opts)
push_op(ctx, ["__instances", [Atom.to_string(name), rows, palette, cols]])
end
defp encode_instances(instance_data, opts) do
{rows_full, _palette_map, palette_rev} =
Enum.reduce(instance_data, {[], %{}, []}, fn inst, {rows, pal, rev} ->
{fill_id, pal, rev} = palette_id(Map.get(inst, :fill), pal, rev)
{stroke_id, pal, rev} = palette_id(Map.get(inst, :stroke), pal, rev)
row = [
quantize(Map.get(inst, :x), :x, opts),
quantize(Map.get(inst, :y), :y, opts),
quantize(Map.get(inst, :rotate), :rotate, opts),
quantize(Map.get(inst, :scale_x), :scale_x, opts),
quantize(Map.get(inst, :scale_y), :scale_y, opts),
fill_id,
stroke_id,
quantize(Map.get(inst, :alpha), :alpha, opts)
]
{[row | rows], pal, rev}
end)
rows_full = Enum.reverse(rows_full)
cols = active_cols(rows_full)
rows =
Enum.map(rows_full, fn row ->
Enum.map(cols, &Enum.at(row, &1))
end)
{rows, Enum.reverse(palette_rev), cols}
end
defp quantize(v, _key, _opts) when is_nil(v) or is_integer(v), do: v
defp quantize(v, key, opts) when is_float(v) do
case quantize_places(opts, key) do
nil -> v
places when is_integer(places) and places >= 0 -> Float.round(v, places)
_ -> v
end
end
defp quantize(v, _key, _opts), do: v
defp quantize_places(opts, key) do
cond do
is_list(opts) -> Keyword.get(opts, key) || Keyword.get(opts, :precision)
is_map(opts) -> Map.get(opts, key) || Map.get(opts, :precision)
true -> nil
end
end
defp template_instance_opts(opts) when is_list(opts) do
opts |> Keyword.take([:precision, :x, :y, :rotate, :scale_x, :scale_y, :alpha])
end
defp template_instance_opts(_), do: []
defp merge_instance_opts(base, override) when is_list(base) and is_list(override),
do: Keyword.merge(base, override)
defp merge_instance_opts(_base, override) when is_list(override), do: override
defp merge_instance_opts(base, _override) when is_list(base), do: base
defp merge_instance_opts(_base, _override), do: []
defp active_cols(rows) do
# Always include x/y; include other columns only if any non-nil appears.
base = [0, 1]
dynamic =
2..7
|> Enum.filter(fn idx -> Enum.any?(rows, &(Enum.at(&1, idx) != nil)) end)
base ++ dynamic
end
defp palette_id(nil, pal, rev), do: {nil, pal, rev}
defp palette_id(style, pal, rev) when is_binary(style) do
case pal do
%{^style => idx} ->
{idx, pal, rev}
_ ->
idx = map_size(pal)
{idx, Map.put(pal, style, idx), [style | rev]}
end
end
defp palette_id(_other, pal, rev), do: {nil, pal, rev}
@doc """
Expands `__instances` ops into plain Canvas 2D ops.
The LiveView hook handles instances natively on the client (more efficient),
but other backends (wx, custom) can call this to get a flat ops list that
any Canvas 2D renderer can execute.
Automatically calls `render/1` first if needed.
## Example
canvas
|> Easel.template(:dot, fn c -> ... end)
|> Easel.instances(:dot, [%{x: 10, y: 20}])
|> Easel.expand()
# ops now contain save/translate/rotate/.../restore sequences
"""
def expand(%Easel{rendered: false} = ctx), do: ctx |> render() |> expand()
def expand(%Easel{} = ctx) do
expanded =
Enum.flat_map(ctx.ops, fn
["__instances", [name, rows, palette, cols]] ->
instances = Enum.map(rows, &row_to_instance(&1, palette, cols || []))
expand_instances(name, instances, ctx.templates)
["__instances", [name, rows, palette]] ->
instances = Enum.map(rows, &row_to_instance(&1, palette, [0, 1, 2, 3, 4, 5, 6, 7]))
expand_instances(name, instances, ctx.templates)
["__instances", [name, instances]] ->
expand_instances(name, instances, ctx.templates)
op ->
[op]
end)
%{ctx | ops: expanded}
end
defp row_to_instance(row, palette, cols) do
col_pos = cols |> Enum.with_index() |> Map.new()
get = fn col_idx ->
case Map.get(col_pos, col_idx) do
nil -> nil
pos -> Enum.at(row, pos)
end
end
%{
x: get.(0),
y: get.(1),
rotate: get.(2),
scale_x: get.(3),
scale_y: get.(4),
fill: palette_lookup(palette, get.(5)),
stroke: palette_lookup(palette, get.(6)),
alpha: get.(7)
}
end
defp palette_lookup(_palette, nil), do: nil
defp palette_lookup(palette, idx) when is_integer(idx), do: Enum.at(palette || [], idx)
defp palette_lookup(_palette, _), do: nil
defp expand_instances(name, instances, templates) do
tpl_ops = Map.get(templates, String.to_existing_atom(name), [])
Enum.flat_map(instances, fn inst ->
style_ops =
[]
|> then(fn ops ->
if inst[:fill], do: [["set", ["fillStyle", inst.fill]] | ops], else: ops
end)
|> then(fn ops ->
if inst[:stroke], do: [["set", ["strokeStyle", inst.stroke]] | ops], else: ops
end)
|> then(fn ops ->
if inst[:alpha], do: [["set", ["globalAlpha", inst.alpha]] | ops], else: ops
end)
|> Enum.reverse()
transform_ops =
[["translate", [inst[:x] || 0, inst[:y] || 0]]]
|> then(fn ops -> if inst[:rotate], do: ops ++ [["rotate", [inst.rotate]]], else: ops end)
|> then(fn ops ->
if inst[:scale_x] || inst[:scale_y] do
ops ++ [["scale", [inst[:scale_x] || 1, inst[:scale_y] || 1]]]
else
ops
end
end)
[["save", []]] ++ transform_ops ++ style_ops ++ tpl_ops ++ [["restore", []]]
end)
end
@doc """
Finalizes the canvas by reversing the ops list into execution order.
Must be called before passing the canvas to a backend for rendering.
Safe to call multiple times - subsequent calls are no-ops.
"""
def render(%Easel{rendered: true} = ctx), do: ctx
def render(%Easel{} = ctx) do
%{ctx | ops: Enum.reverse(ctx.ops), rendered: true}
end
end