# `Fresco.Canvas`
[🔗](https://github.com/alexdont/fresco/blob/v0.5.9/lib/fresco/canvas.ex#L1)

`<Fresco.canvas>` — a layered scene of N images positioned at absolute
coordinates on a virtual canvas, plus an open `extensions` map for
annotation tools (future Etcher), ML overlays, and other peer packages.

Single-image is just the N=1 case — use `Fresco.Viewer` when you want the
bare "pan/zoom one image" component without the scene-document overhead.

## The `.fresco` file format

Serializing a canvas yields a JSON document keyed by:

    {
      "version": "1",
      "canvas": { "width": 4000, "height": 3000, "background": null },
      "images": [
        {
          "id": "img-1",
          "src": "/uploads/a.jpg",
          "x": 0, "y": 0,
          "width": 2000,
          "z_index": 0,
          "natural_width": 2000,
          "natural_height": 1500
        }
      ],
      "extensions": {
        "etcher":     { "version": "1", "annotations": [...] },
        "ml-overlay": { ... }
      }
    }

- **`canvas.width` / `canvas.height`** — the virtual canvas extent in
  canvas pixels. Reset-view fits this rectangle to the viewport.
- **Images** are positioned at absolute canvas-pixel `(x, y)` with
  `width` in canvas pixels. Height is derived from natural aspect ratio
  if `natural_width` and `natural_height` are present (and saved into
  the file for forward compatibility).
- **`extensions`** is an open map keyed by package name. Fresco never
  inspects the inside — each extension owns its own shape and version.
  Unknown extension keys are preserved verbatim across read/write so
  you can load → edit → save without losing data the current version
  doesn't understand.
- **Read-time forward-compatibility:** any unknown top-level or
  per-image key is preserved through a private `__extra__` map and
  re-merged on write. A v1 reader of a future v2 file keeps the v2
  fields it doesn't understand and writes them back unchanged.

## Building a canvas

    iex> canvas =
    ...>   Fresco.Canvas.new(width: 4000, height: 3000)
    ...>   |> Fresco.Canvas.add_image(%{src: "/a.jpg", x: 0, y: 0, width: 2000})
    ...>   |> Fresco.Canvas.add_image(%{src: "/b.jpg", x: 2100, y: 0, width: 1800})
    ...>   |> Fresco.Canvas.put_extension("etcher", %{"version" => "1", "annotations" => []})
    iex> Enum.map(canvas.images, & &1.id)
    ["img-1", "img-2"]

## File I/O

    Fresco.Canvas.write!("/tmp/scene.fresco", canvas)
    canvas = Fresco.Canvas.read!("/tmp/scene.fresco")

Writes are atomic: `write/2` writes to `<path>.tmp` then renames, so an
interrupted save can't corrupt an existing file.

## Extension contract — passive Fresco

Fresco is passive with respect to `extensions`. The file is the source
of truth; updates flow consumer LiveView → `%Fresco.Canvas{}` in
assigns → re-render. A peer package like the future Etcher reads its
initial state via `handle.getExtension("etcher")` at mount, pushes
edits to its own LiveView, which calls
`Fresco.Canvas.put_extension(canvas, "etcher", new_data)` and
re-assigns. Fresco's handle is intentionally read-only for extensions
— no `setExtension` method exists, so save timing is never racing
with annotation updates over channels.

See `Fresco.Viewer` for the simpler single-image component, and
`Fresco.ScrollStrip` for the long-scroll reader counterpart.

# `image`

```elixir
@type image() :: %{
  :id =&gt; String.t(),
  :src =&gt; String.t(),
  :x =&gt; number(),
  :y =&gt; number(),
  :width =&gt; number(),
  optional(:z_index) =&gt; integer(),
  optional(:natural_width) =&gt; number(),
  optional(:natural_height) =&gt; number(),
  optional(:__extra__) =&gt; map()
}
```

# `t`

```elixir
@type t() :: %Fresco.Canvas{
  __extra__: map(),
  canvas: %{
    :width =&gt; number(),
    :height =&gt; number(),
    optional(:background) =&gt; String.t() | nil
  },
  extensions: %{optional(String.t()) =&gt; any()},
  images: [image()],
  version: String.t()
}
```

# `add_image`

Append an image to the canvas.

Required attrs: `:src` (string URL/path), `:x`, `:y`, `:width` (numbers,
width > 0). Optional: `:id` (auto-assigned `img-N` when omitted),
`:z_index`, `:natural_width`, `:natural_height`.

Raises `ArgumentError` on invalid attrs.

# `canvas`

Renders a Fresco canvas — N images positioned at absolute coordinates
on a virtual canvas, with pan/zoom/fit/fullscreen identical to
`Fresco.viewer`. Hooks the FrescoCanvas JS controller.

## Attributes

* `id` (`:string`) (required) - DOM id; must be unique on the page.
* `canvas` (`Fresco.Canvas`) (required) - A `%Fresco.Canvas{}` struct describing the scene: virtual canvas
  extent, the list of images with their canvas-pixel positions, and an
  open `extensions` map. Build one via `Fresco.Canvas.new/1` +
  `Fresco.Canvas.add_image/2`, or load one from a `.fresco` file via
  `Fresco.Canvas.read!/1`.

* `class` (`:string`) - CSS classes for the canvas host container. Defaults to `"w-full h-96"`.
* `infinite_canvas` (`:boolean`) - When `true`, drops the default "canvas must cover viewport" clamp so
  the user can pan freely beyond the canvas edges and zoom out until
  the whole layout is a thumbnail in the middle of an empty workspace.

  Defaults to `false`.
* `theme` (`:atom`) - Color scheme. Same semantics as `Fresco.viewer`'s `:theme`. Defaults to `:system`. Must be one of `:system`, `:light`, `:dark`, or `:inherit`.
* `zoom_floor` (`:float`) - Optional minimum zoom scale, in engine units (screen-px-per-canvas-px).
  When set, the engine clamps every zoom path — wheel, pinch,
  double-click, `fitBounds` — at this floor. `nil` (default) falls
  through to the engine's normal floor (`sFit` clamped mode,
  `sFit * 0.05` infinite-canvas mode).

  Most often set at runtime via `handle.setZoomFloor(scale)` — paged
  readers recompute it each time the user navigates to a new page so
  the floor tracks the current page's fit-to-viewport scale, not the
  whole-canvas fit.

  Defaults to `nil`.
* `zoom_ceiling` (`:float`) - Optional maximum zoom scale. Symmetric to `:zoom_floor`. `nil`
  (default) uses the engine's default ceiling (8× canvas-pixel ratio
  capped by the 8192-px raster safety limit).

  Defaults to `nil`.
* `pan_locked` (`:boolean`) - When `true`, single-pointer pan gestures (mouse drag, touch drag,
  arrow keys) are suppressed. Two-pointer pinch still works for
  zoom. Toggle at runtime via `handle.setPanLocked(true|false)`.

  Defaults to `false`.
* `initial_fit_image_id` (`:string`) - If set, the engine lands at the fit-to-viewport position for the
  image with this `:id` at first paint instead of fitting the whole
  canvas. Avoids the brief flash of "whole-canvas visible" before an
  `onReady` callback re-fits.

  Falls back to canvas-wide fit (with a `console.warn`) if no image
  matches the id. If both `:initial_fit_image_id` and
  `:initial_fit_bounds` are provided, image-id wins.

  Defaults to `nil`.
* `initial_fit_bounds` (`:map`) - Like `:initial_fit_image_id` but for a custom rect. Map of
  `%{x: number, y: number, width: number, height: number}` in
  canvas-pixel coords. Serialized to JSON onto a `data-*` attr and
  parsed by the JS engine at mount.

  Defaults to `nil`.
* `memory_window` (`:integer`) - Auto-evict `src` for images more than this many viewport-widths/
  heights from the current viewport. Same memory-saving trick
  `<Fresco.scroll_strip>` uses, generalized to 2D canvas layouts.

  A value of `2` keeps a 5×5 viewport-rect window of images loaded
  around the current view (1 viewport in the center + 2 viewports of
  padding on each side). Default `nil` = disabled. Evicted images can
  be detected via `handle.on("image-evicted", e => ...)` /
  `image-restored` events.

  Defaults to `nil`.
* `gestures` (`:list`) - Allowlist of enabled gestures. Atom list:
  `[:pan, :pinch, :wheel, :double_click, :keyboard]`. Default `nil`
  enables all. Omitted entries are disabled.

  Useful for kiosks (drop `:keyboard`), swipe-paged readers that handle
  their own page-turn taps (drop `:double_click`), embedded viewers
  that defer scroll to the page (drop `:wheel`).

  Defaults to `nil`.
* `nav_buttons` (`:list`) - Allowlist of enabled built-in nav buttons. Atom list:
  `[:home, :zoom_in, :zoom_out, :rotate, :fullscreen]`.

  - `nil` (default) — every button enabled.
  - `[]` — every button **hidden**. Useful for consumers building
    their own chrome; wire your buttons to
    `handle.zoomIn()` / `handle.zoomOut()` / `handle.rotateBy(90)` /
    `handle.toggleFullscreen()` / `handle.requestHome()` to get
    identical behavior to the built-ins.
  - A subset list — only those buttons render.

  Defaults to `nil`.
* `initial_rotation` (`:integer`) - Initial rotation in degrees, snapped to one of `{0, 90, 180, 270}`
  at mount time. Pre-0.5.7 behavior (no rotation) corresponds to
  `0`. Consumers persisting a per-canvas rotation choice (a
  rotated single panel inside a paged reader, for example) pass
  it here so the first paint already shows the rotated content
  — no flash of unrotated → rotated.

  Runtime control via `handle.setRotation(deg)` /
  `handle.rotateBy(delta)`; the built-in `:rotate` nav button
  cycles `+90°` per click. Only the stage rotates — host
  element, nav overlay, and any consumer overlays outside the
  stage stay unrotated.

  Defaults to `0`.
* `view_tracking` (`:boolean`) - Enables the `view-focus` / `view-blur` event channel for reading-
  time / engagement analytics. When `true`, the engine watches which
  image is dominant in the viewport and emits paired focus/blur
  events on the bus when that image changes. Defaults to `false` so
  consumers who don't subscribe pay zero cost.

  Consumer-side:

      handle.on("view-focus", e => {
        // e.imageId, e.previousImageId, e.atMs
      })

      handle.on("view-blur", e => {
        // e.imageId, e.durationMs, e.atMs, e.reason
        // e.reason ∈ "viewport-change" | "page-hidden" | "disabled" | "destroyed"
      })

  Runtime alternatives if you want to toggle tracking on/off without
  a re-render: `handle.enableViewTracking(opts)` /
  `handle.disableViewTracking()` / `handle.getFocusedImage()`.

  Defaults to `false`.
* `view_settle_ms` (`:integer`) - Milliseconds the viewport must stay on a new dominant image before
  `view-focus` fires. Filters out pan-throughs and momentum-scroll
  fly-bys so analytics events only fire on actual reads. Only
  consulted when `:view_tracking` is `true`. Default `150`.

  Defaults to `150`.
* `view_threshold` (`:float`) - Fraction of an image's area that must intersect the viewport for
  it to qualify as "dominant". Lower values make focus changes more
  eager; higher values make focus stickier. Only consulted when
  `:view_tracking` is `true`. Default `0.5`.

  Defaults to `0.5`.
* Global attributes are accepted.

# `from_json`

Parse a JSON string into a canvas struct. Returns `{:ok, canvas}` or
`{:error, %Fresco.Canvas.SchemaError{}}`.

Unknown top-level and per-image keys are preserved via a private
`__extra__` map; round-tripping through `to_json/from_json` keeps them
intact so v1 readers of a future v2 file don't lose v2-only data.

# `from_json!`

Parse a JSON string into a canvas struct. Raises on invalid input.

# `new`

Build a new empty canvas.

Options:
  * `:width` — virtual canvas width in canvas pixels (default `0`)
  * `:height` — virtual canvas height (default `0`)
  * `:background` — optional CSS color string for the stage background

# `put_extension`

Put or overwrite an extension blob keyed by `name` (binary or atom).

Fresco never inspects the contents — each peer package owns the inner
shape and self-versions inside its own blob.

# `read`

Read a canvas from disk. Returns `{:ok, canvas}` or `{:error, reason}`.

# `read!`

Read a canvas from disk. Raises on failure.

# `to_json`

Serialize a canvas to a JSON string. Returns `{:ok, json}` or
`{:error, reason}` (only on Jason encode failure — schema is validated
at struct-construction time).

# `to_json!`

Serialize a canvas to a JSON string. Raises on encode failure.

# `write`

Write a canvas to disk atomically. Writes to `<path>.tmp` first then
renames so an interrupted save can't corrupt the existing file.

Returns `{:ok, path}` or `{:error, reason}`.

# `write!`

Write a canvas to disk atomically. Raises on failure.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
