# @poetry/charts API

The charts JavaScript surface: the five Stimulus chrome controllers (tooltip, motion, live, window, adapter), the BYO-engine adapter seam, and the motion/live modules - generated from the source JSDoc and the controllers manifest.

## @poetry/charts

@poetry/charts - poetry-charts' Stimulus chrome controllers + the
BYO-engine adapter seam, one source shipped over two channels:
importmap-first (the engine pins this tree; zero build) and this same
tree as the npm package for bundler hosts.

### .registerPoetryChartsControllers(application)

The bundler-host one-liner: registers the five chart controllers on
the host's Stimulus application (importmap hosts get the same
registrations via the engine's pins) and installs the morph guard
below.

## poetry--charts--tooltip

The chart tooltip engine: ZERO chart math in the
browser. The server embeds per-category pixel centers, per-series pixel
extents, and PRE-FORMATTED value strings in a JSON script; this
controller bisects the pointer against those numbers, swaps text into
the server-rendered TooltipContent chrome, positions the box, and
reflects data-active onto the marked cells/dots. Keyboard access is
the accessibility-layer floor: the SVG is focusable, arrows walk the
categories, Escape dismisses.

**Targets**: `data`, `svg`, `tooltip`

**Values**: `sync` (String)

**Events**: `poetry--charts--tooltip:hide`, `poetry--charts--tooltip:show`

### #connect()

Parses the embedded coordinates payload and (once) wires the sync
listener. refresh() re-enters here when the live renderer rewrites
the payload.

### #disconnect()

Unwires the sync listener.

### #count()

### #move(event)

The SVG's pointermove action (cartesian): converts the pointer to
viewBox units and shows the nearest category.

### #enter(event)

The SVG's pointerover action (polar): the hovered slice carries its
index - no geometry at all.

### #keydown(event)

The SVG's keydown action - the accessibility floor: arrows walk the
categories, Home/End jump, Escape dismisses.

### #refresh()

The live-updated action: the live renderer rewrote the embedded
coordinates; re-read them and keep serving the active index if it
still exists (the tooltip follows the stream).

### #focus()

The SVG's focus action: entering by keyboard shows the first category.

### #blur()

The SVG's blur action: dismisses.

### #leave()

Hides the tooltip and clears every active reflection (the
pointerleave action and Escape's landing).

### #show(index)

Shows the tooltip for a category/slice index: swaps the
pre-formatted strings into the server-rendered chrome, positions
the box, reflects data-active, and broadcasts to synced charts.

## poetry--charts--live

The live channel: a chart rendered with live: true carries
its {spec, frame} payload in the DOM; this controller re-renders
client-side (the kernel renderer, in-place attribute updates) whenever
new data arrives - no server round trip. Two channels feed one path:

  1. Replace the payload script's JSON - exactly what a
     turbo_stream.replace/update does. A MutationObserver picks it up.
  2. The JS/event API: dispatch `poetry-chart:update` with
     { detail: { data } } on the frame, or call update(data) on the
     controller instance.

Updates animate through the shared FLIP machinery (old geometry ->
new, the same cross-render morph); a structure change (window growth, series
change) snaps instead of morphing. The tooltip's
embedded coordinates are regenerated by the renderer and a
live:updated dispatch tells the tooltip controller to re-read them,
so it serves fresh values mid-stream. prefers-reduced-motion snaps
every update.

**Targets**: `payload`

**Events**: `poetry--charts--live:settled`, `poetry--charts--live:updated`

### #connect()

Finds the SVG and starts the payload observer (the
script-replacement channel).

### #disconnect()

Stops the observer and abandons any in-flight tween.

### #receive(event)

The event channel's action: a poetry-chart:update event's
detail.data re-renders.

### #setWindow(window)

Applies a window: [start, end] inclusive indices into the FULL
data; the renderer slices before computing. Instant renders - a
drag should track the pointer, not tween behind it.

### #toggleSeries(event)

The interactive legend's click action (the series key rides an
action param): hidden series leave the domain (the chart rescales
on hide), their marks hide, their legend item dims.

### #update(data)

The JS API: hosts hand over the new data rows; the payload script
is rewritten first (the DOM always holds current state), then
rendered.

## poetry--charts--motion

The chart motion engine: the JS half of the entrance tier plus
the cross-render morph. Cartesian + radar entrances are pure CSS (the
motion stylesheet); this controller adds what CSS cannot do - the polar
fan-out and the FLIP morph between server renders - and stamps a
data-motion lifecycle attribute ("entrance" / "morph" -> "settled") on
the SVG so tests and hosts can observe the engine.

ENTRANCE. The fan-out is a step accumulator: every sector's
angular width interpolates 0 -> final
simultaneously, re-accumulated end-to-end each frame from the group's
first startAngle, with the FINAL-geometry gaps preserved as constant
padding. Sectors carry their server-computed params in
data-motion-sector and their sweep group in data-motion-group (pie: one
group per ring; radial: one per band). Mid-sweep paths are plain
sectors; the exact server d (corner rounding included) is restored on
the final frame.

MORPH. Charts with a stable id keep their last geometry in a
module registry when they disconnect; when a same-id chart connects
within the freshness window (any same-context DOM swap - Turbo Drive /
Frames / Streams / morph), the new render starts FROM the old geometry
and tweens to its own via the shared FLIP machinery (motion/flip.js -
the live tier rides the same module). Any structure change
aborts the morph and the normal entrance replays instead - added or
removed data never morphs.

prefers-reduced-motion (or animate: false) settles immediately - the
server-rendered chart is already the finished state.

**Events**: `poetry--charts--motion:settled`

### #connect()

Resolves the SVG, wires the Turbo page-morph hooks (the body
comment holds the blank-entrance rule), and starts the entrance or
morph.

### #disconnect()

Unwires the Turbo hooks, abandons any tween, and parks the current
geometry in the registry for a same-id successor.

## poetry--charts--window

The window controller: brush + zoom over the live
renderer. One concept drives both - frame.window = [start, end]
(inclusive indices into the FULL data) slices the data before the
kernel computes. The brush drags the window (handles resize it, the
body shifts it); zoom drag-selects a range on the plot; double-click
resets. Window changes go through the live controller's setWindow
(instant renders - no tween mid-drag), and this controller repaints
the strip visuals from index fractions of the server-computed rects
(its Stimulus values) - no chart math.

**Values**: `zoom` (Boolean); `plot` (Array); `brush` (Array)

### #connect()

Resolves the SVG/selection nodes and paints the initial brush.

### #disconnect()

Unbinds any in-flight drag listeners.

### #startBrush(event)

The strip's pointerdown action: begins a brush drag - a handle
resizes its edge, the body shifts the whole window.

### #startZoom(event)

The SVG's pointerdown action (skips brush drags): begins a
drag-select zoom on the plot.

### #reset()

The SVG's dblclick action: restores the full window.

## poetry--charts--adapter

The adapter mount: reads the FROZEN chart-spec v1 the
server embedded, hands it to the registered engine adapter, and owns
the lifecycle - render on connect, destroy on disconnect (Turbo-safe),
themeChanged on dark-mode flips. The helpers close the canvas gap:
resolveColor turns var(--color-key)/var(--chart-N) into concrete
values at paint time (CSS variables cannot reach a canvas).

**Targets**: `mount`, `spec`

**Values**: `engine` (String)

**Events**: `poetry--charts--adapter:rendered`

### #connect()

Mounts the declared engine: parses the embedded spec, validates the
adapter and its type support (console errors, never throws - a
chart must not take the page down), renders, and arms the dark-mode
observer.

### #disconnect()

Destroys the engine instance and stops the theme observer.

## @poetry/charts/adapter_registry

The BYO-engine seam: adapters register by name and
implement the duck-typed protocol over the FROZEN chart-spec v1 -

  {
    render(el, spec, helpers)  -> instance   (required)
    destroy(instance, el)                    (required)
    update(instance, spec)                   (optional - live updates)
    themeChanged(instance, spec, helpers)    (optional - dark-mode repaint)
    degradations: ["..."]                    (declared, never discovered)
    supports: ["area", "bar", ...]           (optional type allowlist)
  }

The spec is closed: no engine-specific key ever enters it - a
pass-through options bag would leak engine keys into every call site.
Engine-specific styling lives INSIDE the adapter.

### .registerChartAdapter(name, adapter)

Registers an engine adapter under a name. The adapter must implement
the duck-typed protocol above; a missing render/destroy throws at
registration, never at first chart.

Raises `Error` - when render or destroy is missing

### .chartAdapter(name)

The registered adapter for a name.

### .registeredAdapters()

The registered adapter names, in registration order.

## @poetry/charts/motion/flip

The FLIP geometry machinery (shared by the motion and live tiers):
snapshot a chart's animatable geometry, pair it against the
current DOM, and tween old -> new. Paths morph by pairwise numeric lerp
behind a structure fingerprint, sectors by param lerp through the same
sectorPath port, dots by cx/cy. Both endpoints are always
server-computed (or kernel-computed) states - the only math here is
a + (b - a) * t.

### .parsePath(d)

d -> { skeleton, numbers }: the non-numeric shape (command letters
and separators) plus the numbers in order. Equal skeletons morph; arc
flags are structural constants inside the skeleton's A-segments and
stay put under lerp because equal skeletons imply equal flags.

### .buildPath(skeleton, numbers)

Rebuilds a d string from a skeleton and numbers (2-decimal rounding).

### .keyedElements(svg)

Yields [key, element] for every animatable element, keyed by slot +
series key + per-bucket sequence (dots carry no index; document order
is stable).

### .captureGeometry(svg)

Snapshots the animatable geometry (sector params, path d, or dot
cx/cy), keyed for pairing.

### .matchJobs(svg, previous)

Pairs every animatable element with its captured predecessor. Null =
structure changed (missing key, skeleton mismatch, kind mismatch) -
the caller falls back to its non-morph path.

### .applyJobs(jobs, eased)

Writes one interpolated frame of every job onto the DOM.

### .finishJobs(jobs)

The final frame restores the exact target strings (server- or
kernel-rendered), never a lerp artifact.

## @poetry/charts/adapters/chartjs

The Chart.js reference adapter: the proof of the seam,
and the documented escape valve for the one case the SVG default is
weak on (huge point counts - canvas draws one bitmap). poetry ships NO
Chart.js: the host imports it and passes the constructor in -

  import Chart from "chart.js/auto"
  import { registerChartAdapter, createChartJsAdapter } from "@poetry/charts"
  registerChartAdapter("chartjs", createChartJsAdapter(Chart))

Declared degradations (the canvas tax, stated instead of discovered):
tooltips/legends fall back to Chart.js's built-ins (poetry's HTML chrome
cannot overlay per-point without the engine's hit data), CSS variables
resolve to frozen pixels at paint (dark-mode flips repaint the whole
chart), and per-part data-slot styling does not exist on a bitmap.

### .createChartJsAdapter(Chart)

Builds the Chart.js adapter around a host-supplied constructor
(poetry ships NO Chart.js - the header shows the wiring).

## @poetry/charts/live/renderer

The live cartesian renderer: {spec, frame} + data ->
geometry via the vendored kernel, applied to the server-rendered SVG as
IN-PLACE attribute updates - never innerHTML - so the tooltip's targets,
focus state, and host listeners survive every tick.

This is lib/poetry/charts/cartesian.rb (+ the bar component's slot and
path math) transcribed conventions-and-formatting exact: the vendored
kernel generated the Ruby port's oracle fixtures, nice ticks are the
same getNiceTickValues the server ports, fnum mirrors Ruby's 2-decimal
half-away-from-zero rounding with native JS stringification, and paths
keep the kernel's default 3-digit output - so a kernel render of
dataset X is byte-equal to the server rendering dataset X (proven by
the live_fixtures parity suite).

Scope (declared): the streaming trio (area / line / bar), dense data,
no labels, no tick formatters (the Ruby side raises on both at render).
Count changes (sliding-window warm-up) reconcile group children by
cloning the last sibling - still attribute-channel: engine-owned
subtrees outside the group are never touched.

### .displayValue(value)

ActiveSupport number_to_delimited, byte-for-byte for numerics:
integer part grouped with commas, decimal part verbatim, strings
untouched.

### .computeCartesian(payload)

{spec, frame} -> geometry: the window slice, the hidden-series
filter, scales, stacks, nice ticks, and the per-series point accessor
(the transcribed pipeline the header pins).

### .applyCartesian(frame, payload)

Applies a payload (whose spec.data is the CURRENT data) to the frame
element (the div wrapping svg + chrome + scripts) as in-place
attribute updates.

## @poetry/charts/motion/sector

The sector path, ported from lib/poetry/charts/polar.rb so both
sides emit identical path strings. Angles are degrees
counterclockwise from 3 o'clock, negated into SVG's y-down plane; the
delta clamps at 359.999 so a full circle's endpoints never coincide.
4-decimal formatting matches the Ruby fmt (native JS stringification IS
the reference Geometry.js_number emulates), so mid-sweep paths and the
server's plain paths are byte-compatible.

### .polarToCartesian(cx, cy, radius, angle)

Degrees-and-radius to SVG coordinates (the y-down plane).

### .sectorPath(cx, cy, innerRadius, outerRadius, startAngle, endAngle)

The annular-sector path for a pie/radial slice; inner radius 0 closes
to the center instead of an inner arc.

## @poetry/charts/motion/tween

The tween kernel: one rAF loop, the CSS-named cubic-bezier
easings, cancelable. An animation runtime scoped to poetry's
doctrine - the client only interpolates between server-computed
states, so the whole kernel is eased time.

### .cubicBezier(x1, y1, x2, y2)

A CSS cubic-bezier timing function: Newton-Raphson with a bisection
fallback - the standard solver (the same approach WebKit ships).

### .tween({ duration, delay = 0, easing = "ease", onFrame, onFinish })

Runs one rAF tween. onFrame receives (eased, linear); the final frame
is exactly (1, 1). A non-positive duration fires the final frame
synchronously.