All notable changes to Visualize are recorded here, following Keep a Changelog and Semantic Versioning.
Note. This file is written for a consumer of the library, so it says what changed in the surface and in behaviour, not what changed in the repository. What the library is, module by module, lives in
spec/, which is the source of truth for that and is not restated here.
0.2.25 — 2026-10-10
Added
Visualize.Geo.Circle(#525): d3-geo'sgeoCircle.polygon/1returns the circle ofradiusdegrees (default 90) about acenter[lon, lat](default[0, 0]), everyprecisiondegrees (default 6), as a GeoJSONPolygon— d3-geo 3.1.1's ring point for point, in d3's winding, soVisualize.Geo.Pathfills the cap about the centre under any projection, cut and closed at the antimeridian. A night side ispolygon(center: antisolar_point).alphaandtickson the:forcestep (#527, D-132): the temperature a compiled chart reheats the layout to each tick (0.3) and the iterations it runs (3).Visualize.Layout.Force.run/1takes:alphaand:alpha_decay(#527): a run can continue a layout, from nodes that carry theirx,y,vxandvy, as a reheated d3 simulation does. The defaults are unchanged.Visualize.Chart.Frame.new/2takeswarm:(#527, D-132): the force layouts a compiled chart carries between ticks.
Changed
- The cheatsheet links open its rendered page on HexDocs (#528).
guides/cheatsheet.cheatmdis ExDoc's cheatsheet format, so GitHub and GitLab show it as plain text. The README and the guides now link https://hexdocs.pm/visualize/cheatsheet.html. - A projection's clip hides a row; it no longer drops it (#522, D-130). A
:projectionstep keeps every row it is given. A point the projection clips keeps its row withxandyset tonil. A geometry that projects to nothing keeps its row withpath,xandyset tonil. Marks draw nothing for these rows on every backend. Domains are now inferred over every row, whatever the viewpoint. Before, a turning globe coloured through an inferredordinal_scale(:color)changed its land colours as it turned, and a bubble map's size domain depended on what was in view. Rows with a missing or non-numeric coordinate, or a geometry that is not a map, are still dropped. If you count or read the step's output rows, expect the clipped ones, withnilpositions. - A
:lineor:areapath that places no point draws no element (D-130). This applies to a whole series a projection clips, and to a line with no rows. Before, it drew an empty<path d="">. - A compiled chart's force layout is warm (#527, D-132).
Visualize.Chart.Compiledkeeps each:forcestep's last layout, every node's position and velocity by id, and eachtick/3orstep/3moves the graph on from it instead of laying it out again from scratch. A change to the step'sdistanceorstrengthnow perturbs the graph rather than replacing it with a new one, often a reflection or rotation of the last. A node new to the graph starts near its linked neighbours, and a node that has gone is forgotten.Compiled.carry/2carries the layout across a recompilation, so a caller that compiles again because a tick's marks differ keeps the graph where it was.Visualize.Chart.apply/2andrenderare unchanged: the same input lays out the same graph, cold. - The marks of one graph share one layout (#527, D-132).
Visualize.Chart.Frame.new/2lays out each distinct:forcestep once and holds it in the frame's newforcesfield, so a links mark and a nodes mark over the same step agree exactly and the simulation runs once per realisation, not once per mark and pass. Compiling a graph is about eight times cheaper. - A
:forcenode source'sxandyseed the layout (#527). A node whose row has numericxandystarts there, as d3 honours a given position.
0.2.4 — 2026-10-10
Changed
- Visualize is on Hex (#516): depend on
{:visualize, "~> 0.2"}rather than a git tag. The documentation is on HexDocs. The source, and issues and feedback, are at github.com/dcoai/Visualize.
0.2.0 — 2026-10-10
Upgrading from 0.1.0. This is the first minor since the first tag, and the breaks are in
the declarative layer and the builder. The imperative pipeline (Scale → Shape →
IR.Element → Render) removes nothing. A design now holds its frames by name and
records no size: the host gives size: at Visualize.Chart.apply/2, and
Visualize.Chart.compile/2 returns one compiled chart per frame. Stored version-1 designs
still read, because from_map/1 and from_json/1 migrate them, but code that builds
designs, reads a compiled chart, or implements Visualize.Chart.Builder.Store needs the edits
listed under Breaking. Three d3-conformance fixes in geo and stacking also move output for
the same input. Read those if you draw globes, rotate projections, or stack :insideout.
Breaking
- A design holds its frames by name, and the schema is version 2 (#383, #428). The
design's
frame:key isframes: %{main: …},%Visualize.Chart{}hasframeswhere it hadframe, andto_map/1writesversion: 2(Visualize.Chart.Migration.current/0). Affects code that writes design maps or fragments by hand, or matches on the struct. Instead: writeframes: %{main: %{kind: :cartesian, …}}, and useVisualize.Chart.Build.in_frame/2to put fragments under another frame. A whole design that saysversion: 1still works:from_map/1,from_json/1,apply/2andcompile/2migrate it first (#479). A fragment carries no version, so it is not migrated, and a fragment written withframe:is an unknown key.Visualize.Chart.Appliedgainsframes. Itsframeis the first frame by name. - A design records no size (#427). A frame has no
sizekey and the:sizenode kind is gone. Instead: pass the host's container assize: {width, height}toVisualize.Chart.apply/2orVisualize.Chart.compile/2(orVisualize.Chart.Frame.new/2). The default is{600, 400}. Migration drops a version-1 design'sframe.size. Such a design now draws at 600×400 unless you passsize:. A version-2 design that still sayssizefails validation as an unknown key. Build's frame functions take a name, not a size (#427, #383).Build.cartesian(width, height, opts)and the same three-argument forms ofpolar,geoandfacetare removed. The two-argument forms now mean(name, opts), socartesian(600, 400)raises. Instead:Visualize.Chart.Build.cartesian/0,cartesian(opts)orcartesian(:name, opts), and the size at apply.Visualize.Chart.Build.scale/3,axis/3andlegend/2now write intoframes.main.Visualize.Chart.compile/2returns one compiled chart per frame (#431, #385). It returns{:ok, %{main: compiled}}where it returned{:ok, compiled}. Instead: match the frame you want ({:ok, %{main: compiled}} = Chart.compile(design, opts)), or map over the result for a design of several frames.- A compiled chart no longer carries its sources (#412). The
sourcesfield is gone from%Visualize.Chart.Compiled{}. Affects code that readcompiled.sourcesor matched on it. Instead: keep the rows you passed.Compiled.render/2,svg/2andtick/3take the tick's sources as before. Visualize.Chart.Builder.Storeis version 2 (#173, D-99, D-100, D-103). The 0.1.0 callbacks werelist/0,get(name)andput(name, map)returning:ok. Now:- Every callback takes a
context, and an entry has an id. The callbacks arelist(context), which returns[%{id, name, kind}], thenget(id, context),put(entry, context), which returns{:ok, id},delete(id, context), andimport(bundle, context). An entry is
%{id: nil | id, name, kind, fragment}. The store issues ids as{kind, n}and never reuses one. Thenameis a"group:sub-group:name"label.- The
storeassign may be{module, context}. Visualize.Chart.Builder.Store.relocate/3is a referenceimport/2over yourput/2.- The kind is
Visualize.Chart.Fragment.kind/1, one ofVisualize.Chart.Fragment.kinds/0, which has no:design(#187). Save a composite; a design is what deploying produces.
- Every callback takes a
- The builder's save message carries structure, not a flat design (#178, #183, D-103).
{on_save, id, structure}is now a composite ofVisualize.Chart.Usesites holding store ids. Instead: flatten it withVisualize.Chart.flatten/2, which returns{:ok, design, report}. Or handle the new{on_deploy, id, design}message, which the builder's Deploy button sends with the flat design. The tag is theon_deployassign, default:visualize_chart_deployed. - The validator refuses designs 0.1.0 accepted (#368, D-113; #487, D-122). A reference
into a declaring map the design never wrote is now reported. For example,
data: :swith nosourcesis{:undeclared, :source, :s}, where 0.1.0 treated the missing map as unknown. A colour channel's constant value, or a typed column, that a declared ordinal colour domain can never hold is{:outside_domain, scale, value}. Instead: declare what you reference, and widen the domain or drop the constant. - A stack's
:insideoutorder is d3'sstackOrderInsideOut(#496, D-125). It put the heaviest series at the bottom, where the spec said the middle. It now takes the series in order of appearance (by the index of each one's peak) and places each on whichever side has the smaller running sum, so the earliest-peaking series sits in the middle and later ones outward. Any stack ordered:insideout, or a design's:stackstep ordered:inside_out, stacks in a different order. Instead: to keep a fixed order of your own, useorder: {:keys, list}(#492, below). - A projection's
phiandgammaare d3's (#511, D-129).phinow tilts the globe andgammarolls it, with d3's axes, order and sign. What 0.1.0 calledphiis nowgammawith its sign flipped, and 0.1.0'sgammahas no equivalent. Instead: a rotation{λ, φ, 0}from 0.1.0 is{λ, 0, -φ}now.lambdakeeps its opposite-to-d3 sign (D-44), so a rotation copied from d3 keepsφandγand negatesλ. - Projected GeoJSON is clipped on the sphere as d3-geo clips it, so winding matters
(#509, #510, D-127).
Visualize.Geo.Pathcuts lines and polygons at the antimeridian of the rotated frame, and under aclip_anglealong the small circle. It no longer drops vertices. An anticlockwise ring is the rest of the sphere, as in d3. Instead: rewind data in RFC 7946's winding before drawing it (Natural Earth as d3 ships it needs nothing). The defaults change too:- The azimuthal clip angles are d3's:
90 + 1e-6for the orthographic,180 - 1e-3for the azimuthal equal-area and equidistant. bounds/2andcentroid/2read the clipped vertices.- A ring that crosses nothing keeps its vertices exactly.
- The azimuthal clip angles are d3's:
- The incremental
0x60scroll record carries its viewport (#295, D-106). Sixteen more header bytes,vx vy vw vh, follow the offset, making a fixed 27-byte header. The shippedCanvasIncrementalCharthook reads it, so this affects only a host that decodes the stream itself.Visualize.Backend.CanvasIncremental.encode_incremental/4takes the rectangle asviewport:, the whole canvas by default.
Added
Documentation
- Guides (#515): Getting started, Charts in LiveView, Designing charts and a cheatsheet, under Guides in the docs. Every Elixir block in them and in the README runs in the test suite, so they cannot fall behind the code.
- The README is a short introduction: what visualize is, why, how to install it and run the examples, and where the documentation is.
Declarative charts
- Several frames in one design (#428, #437, #436, #438). A frame takes a
box(fractions of the render's size), azand abackground. A design'slayout(Visualize.Chart.Build.layout/1:columnsandrowsas weights,gapin pixels) places frames bycellandspaninstead of arithmetic. A frame can adopt another frame's scale, so two frames share a domain while each keeps its own range (Visualize.Chart.Build.adopt/2,{:frame, :main, :x}). A composite is placed as frames by its key.Visualize.Chart.generate/2withroot: truerenders a whole design as an accessible<svg>document (#131). - New marks:
:text(#337), with edge anchors for the rectangular types.:needle(#392,Visualize.Shape.Needle).:tiles(#475; see Geo).
seriesis a channel of every mark that draws rows (#508, D-126). Every element and label of a series carriesdata-series, so a legend toggle hides a series' points with its line.- New data steps:
:fold(#240): wide columns to long rows.:takeand:window(#426): the newest rows or a duration back fromnow:.:spectrum(#375): a one-sided amplitude spectrum by FFT.:lttband:m4(#448): shape-preserving downsampling.:hexbin(#353).:projectionover a geometry column, with the Sphere (#350).- A graph's nodes as a second source, and
strengthanddistanceon:force(#352, D-110).
- Polar as a coordinate transform (#390–#393). An angle scale carries
startandsweep. Every ordinary mark is laid out in (angle, r) and bent around the arc. A categorical angle closes its ring, which gives the radar. Labels take:outward/:inwardanchors withdr,rotatein degrees or:tangent/:radial(#338), and a{:frame, :center}anchor (#489). - Scales:
- Offset scales, a band within a band, which gives grouped bars (#339, D-109).
- A time scale's display zone (#447; see Data and scales).
{:scale, :min}and{:scale, :max}as a channel value, so an area can fill to its axis whatever the domain (#420).- A
transitionnode on a scale or a mark eases a moving domain or value between ticks in a compiled chart (#360, #417).Visualize.Chart.Compiled.step/3steps one frame, andVisualize.Chart.Compiled.carry/2keeps the easing across a resize (#434).
- Styles:
- Gradients in
defs, used as{:paint, name}; seeVisualize.Chart.Build.gradient/3andpaint/1(#133, #219). - A fill style; a stroke style of single, double, inside or outside (#218); blend mode and shadow or blur effects (#220).
font_styleand the weight words (#217).- Multi-line text with
line_height(#221);vertical_align,margin_x,margin_yandtext_angle(#222). - A style site takes a stack of styles (D-98).
:contrastas a colour (#486, D-123): the theme's text or background, whichever reads against the fill under the label, with WCAG AA guaranteed. SeeVisualize.Theme.ink/2.- A mark label's
fit::truncateor:hide(#138).
- Gradients in
- Axes and legends: a legend outside the plot with
inside: false(#347);prefixanduniton an axis or legend (#351);format: :durationon an axis (Visualize.Format.duration/1, #191). - Typed columns (#369): a source's
typesdeclares each field as:time,:number,:categoryor:text. SeeVisualize.Chart.column_type/3andVisualize.Data.Table.column_types/1. - Sync groups share hover (#466, D-116). A design key,
interaction: %{sync: "<group>"}, makes charts on one page share a cursor. Hovering one moves the crosshair and the tooltip of every other member to the same x in domain units, through each chart's own scale, so members may differ in width and margin. No host JavaScript is needed: the frame rendersdata-vis-syncanddata-vis-sync-x(Visualize.Chart.Frame.sync_attrs/1, also inVisualize.Hooks.Crosshair.attrs/3), andTooltipHookandCrosshairHookexchangevis:sync:<group>events ondocument. Only a linear or time x can join a group. The validator refuses any other kind as{:sync, :x_scale, kind}.Visualize.Chart.Build.interaction/1writes the key. A chart'sto_map/1and JSON now carryinteraction(default%{}). - Sync groups share the brush (#467, D-117). A window brushed on one member shows as a
band on every other, and only the chart brushed pushes to its server. The bus is
Visualize.Hooks.Sync.js_bus/0, bundled ahead of the hooks that use it. - A stack order fixed by explicit keys (#492).
Visualize.Shape.Stack.order/2and a design's:stackstep take{:keys, list}, the listed keys bottom first and every key the list leaves out above them in key order; a listed key the stack lacks is ignored. Every other order is computed from the data each call is given, so over an animated or streaming source:inside_outre-sorts per frame and layers swap places; a fixed order computed once does not. In JSON it is{"$keys": [...]}. A:sortstep'sorderis still a direction alone. - Use sites and composites (#174, #186, #188, D-101, D-102). A composite is a list of
Visualize.Chart.Usesites: a reference, a local body, a mask and bindings, resolvedref → local → mask → bind. SeeVisualize.Chart.bind/2,mask/2andresolve/2, andVisualize.Chart.flatten/2, which turns a composite into a flat design through a fetch.Visualize.Chart.FragmentandVisualize.Chart.Compositehold the walks.Visualize.Chart.Validator.validate/2validates a fragment as its kind. - A public JSON codec for fragments (#465).
Visualize.Chart.Fragment.to_json/1andfrom_json/1write and read any fragment, a composite of use sites holding ids included, in the JSON form spec/14 §19.7 specifies ($use,$id). It is not validated as a design, since a fragment may be partial. A store that keeps its library in a database can now persist whatStore.put/2hands it.Chart.from_json/1validates a whole design and so refuses a composite, and the codec under both was private. - Node paths on request (#260, D-105).
paths: trueonVisualize.Chart.Frame.generate/2stampsdata-nodeon everything a frame draws. The default render is unchanged.
Rendering and backends
Visualize.Render.to_png/2andto_png!/2(#473, #474, #481, D-121). A root IR element, or the SVG string it renders to, is rasterised to a PNG by the resvg command-line tool, run as an OS process over a port.- No Hex dependency. The host installs resvg 0.45 or later (
apt install resvgon Debian and Ubuntu, the upstream release tarball, orcargo install resvg). Name it withconfig :visualize, :resvg, "/path/to/resvg", or leave it on thePATH. - When resvg is missing or too old. Without one, the result is
{:error, :no_rasterizer}. An older one gives{:error, {:rasterizer_version, found, required}}. - Isolation. A render holds no BEAM scheduler, cannot crash the VM and writes no temp file.
- Options.
scale:is the device pixel ratio andbackground:a CSS colour.timeout:(default 30 s) bounds the render: on expiry resvg is killed and the result is{:error, :timeout}. - Literal colours only. Generate the SVG with
resolve: :literal. Avar(--…)theme reference returns{:error, :css_references}, since resvg would paint it black.
- No Hex dependency. The host installs resvg 0.45 or later (
- PNG fonts and warnings (#474, #481).
to_png/2takes the font configuration:font_dirs:,system_fonts:(defaulttrue) andgeneric_families:(resvg's defaults, wheresans-serifis Arial).font_family:covers text that names none (default"sans-serif"). resvg loads the fonts on each call, so a font added to a directory is seen by the next render.- The success value is
{:ok, png, warnings}, resvg's own report. There is one{:missing_family, list}per font-family list no font resolves, whose text was left out, and{:rasterizer, line}for anything else resvg printed. to_png!/2raises when there is a warning.- The gallery has raster goldens, rendered with a bundled DejaVu Sans and no system fonts.
- The success value is
- A compiled chart's backdrop (#476, D-120).
Visualize.Chart.Compiled.backdrop/1, and thebackdropkey ofrender/2's map and oftick/3's payload, hold a:tilesmark's images as an SVG document. A page stacks it beneath the canvas, so a dense track drawn on the canvas sits on its basemap.- The attribution stays in the SVG layer over the canvas.
Compiled.static/1no longer holds a static basemap's images. A page that draws tiles on the hybrid split stacks the backdrop.- The binary stream still drops
:imageand gains no record for it.
- A theme's
:surfaceslot (#422). It is the plane a chart's data is laid on, between the background and the grid:#eef2f6onVisualize.Theme.default/0and#23272fondark/0. It is a colour slot like any other.slots/1andcolour_slots/1list it, andresolve/3givesvar(--vis-surface, …)on SVG and the literal on canvas. An inline:themenode takes asurfacekey. A theme fromnew/1that names none takes the light value, as it does for every field it leaves out. - A reference decoder for the binary canvas stream
(
Visualize.Backend.CanvasBinary.Decoder, #291). - New IR primitives:
Visualize.IR.Element.clip/3,filter/2,drop_shadow/3,gaussian_blur/1,tspan/2,put_attr/3, andVisualize.IR.Path.from_commands/1.
LiveView and hooks
- Frame acknowledgement (#305, #307, #322, D-107).
CanvasIncrementalChartandCanvasBinaryChartacknowledge each payload'sseqwhen their element carriesdata-ack. A producer can then bound frames in flight, and a hold times out rather than stall. This is opt-in, and existing uses are unchanged. - The sync-group hover and brush above need no host JavaScript beyond the shipped hooks.
Geo
- Basemap tiles (#475, #476, D-119, D-120). A
:tilesmark draws Web-Mercator tiles beneath a geo frame's other marks, at the nearest zoom, over a projection fitted without moving its centre. SeeVisualize.Geo.Tiles(aligned/1,zoom/2,cover/3,url/3). Visualize.Geo.Projection.sphere/1(the outline of the visible globe),fit/3andprecision/2, andVisualize.Geo.Path.path/2.
Data and scales
- Time zones (#447, D-115).
Visualize.Scale.Time.zone/2(andVisualize.Scale.zone/2, or a design'szonekey) ticks on local calendar boundaries across DST. It reads the zone through the host's configuredCalendar.TimeZoneDatabase, so the library takes no dependency for it.- A zone the database cannot show raises
ArgumentErrorat the setter, and in a design it is{:zone, name, reason}. - Without a zone, every tick is the UTC one byte for byte.
Visualize.Scale.Time.local/2converts a value into the scale's zone.
- A zone the database cannot show raises
- Downsampling:
Visualize.Data.lttb/3andVisualize.Data.m4/3(#448). Visualize.Data.FFT(fft/1,spectrum/3,window/2, #375).Visualize.Signals(#247): deterministic sine, square, random and discrete sources over a sliding window, for a chart that moves without a host feed.Visualize.Format.duration/1(#191),Visualize.Layout.Hexbin(#353), the closed curvesVisualize.Shape.Curve.basis_closed/1andcardinal_closed/2(#393),Visualize.Shape.Arc.angles/2andradii/2, andVisualize.Contour.cost/2(#462).- A force simulation's subscribers at start (#483):
Visualize.Layout.Force.Simulation.start_link/1takessubscribers:, pids registered before the first tick, so a subscriber sees every tick from the first.subscribe/2still joins a running simulation.
Builder
- The builder became an editor of typed fragments (#142–#388, spec/14 §18–§19).
- Layout. It ships its own stylesheet, rendered inline.
styles={false}turns that off, and the host then servesVisualize.Chart.Builder.css/0itself (D-94). The workspace holds several charts beside a library tree. - Library entries. A library drop arrives linked. Unlocking it copies the body into the site, and a site can mask paths and bind variables.
- Editing. It has a Variables tab, a style form, a data page with typed columns and generated signals, axes ticked per side, and "add data / add a mark / add axes" steps.
- Saving. Save sends structure; Deploy sends the flat design (above).
- New assigns:
on_deploy,source_defaults,tick_msandstyles. - Import and export. These move bundles:
Visualize.Chart.Builder.Bundle.export/3, and the store'simport/2.
- Layout. It ships its own stylesheet, rendered inline.
- A referenced group expands in the builder's tree (#388). A library composite dropped
linked now carries the ▸ and the count of its entry's sites. Opened, it draws them beneath
its row, muted and locked, each tagged with the entry's name.
- They are the library's, so they take no flat position of their own. A click, a drag or the menu on one acts on the group.
- A drop into the group is refused with name is from the library — open it to edit.
Visualize.Chart.Builder.Stack.all/2is new: the tree's rows, with each referenced group's inside fetched and each row naming itsowner. So areinside/2andinner_label/3.
Changed
- A colour outside its domain is the scale's
unknown(#487, D-122). Acolorordinal scale that gives nounknownnow takes the theme's:axiscolour. In 0.1.0 itsunknownwasnil, so the element got no fill: SVG painted it black and a canvas drew nothing. A series line outside the domain is drawn in that neutral, not in the series colour of its position. Sayunknown: :noneon the scale to hide such values. A missing colour is now bound as:none, which draws nothing on SVG and canvas alike. - The pie, sunburst and treemap components ink their labels by contrast and fit them
(#494, #497, D-123). Their labels are now
:contrastwith the gallery'sfit(:hideon the pie,:truncateon the sunburst and treemap), so a label too big for its slice is hidden or cut. The markup of those labels changes. Every path and the rest of the shell are byte for byte as before. - A labelled mark is a wrapper of two groups (#485). The elements are in a styled group
and the labels in an unstyled sibling group, so labels no longer inherit the mark's
stroke. The wrapper keeps the mark class,
data-node, the tooltip attributes and the centring transform. - An area's default baseline is the y scale's zero clamped to the plot (#420). An area
over a domain that excludes zero no longer fills into the margin. An explicit
y0is placed as given. - A line whose style gives a fill draws the area under it, down to the baseline (#232), where it used to fill the polygon between the curve's ends.
- A
:chordstep centres on the plot, as an arc mark does (#488). Itssizesets the radius only. - The builder's
layersassign seeds the stack and does not control it (#144, D-95). An unrelated host render no longer resets an editing session. A changed list is adopted. - The canvas hooks size the canvas to
data-width×data-heightat every draw (#456). A resize clears it, and the incremental hook draws no scroll record until a full frame follows. - A canvas draws by the effective style (#231, D-111). A mark whose paint sits on its group draws on canvas as on SVG. The binary stream gains one style record per mark group.
Fixed
- A drop below a closed group in the builder's stack lands where it was aimed (#468).
BuilderHookcounted the drawn rows to name a drop's position, while every flat position the builder reads counts a closed group's rows too. So a drop between a closed group and the row after it landed inside the group, and a group dropped directly below itself moved.- A stack row now carries
data-builder-end, the flat position past everything it holds, beside itsdata-builder-layer. The hook reads both instead of counting. - The gap before a row is its position, and the gap at the end is the last row's end.
- A move shifts by the rows the dragged one lifts out.
- A stack row now carries
- A composite exports and imports with its references (#470).
Fragment.refs/1andrelocate/2treated every struct as a leaf. A composite's sites are%Use{}structs holding ids inref,origin,localandvars. SoBundle.export/3of a composite left out what its uses referenced, and an import left it pointing at the source store's entries. The two id walks now read a use's parts. Every other walk still stops at a struct (D-92). - A glyph carries no id (#471). The
:linearand:radialfill-style pictures and the:blureffect drew an inline<defs>with a fixed id. The editor shows a glyph once per choice and per open row, so a page repeated the id, which LiveView refuses (Duplicate id found … vis-glyph-linear). They are now translucent bands and squares, with no paint server and no filter. The glyph test also walks the four drawn keys it had skipped (fill_style,effect,vertical_align,stroke_style). - A version 1 design read from JSON migrates (#479). The codec typed every key by the
current schema, so a v1 design's
framekept string values and failed validation once migrated toframes.main. So JSON-stored designs from before #383 couldn't be read, which contradicted §9.Migration.legacy_type/2gives a removed key its old type, and the codec reads by it, so the document is typed before it is migrated.- §14.4 also corrects
current/0to2.
- An adopted scale has a JSON form (#480). A frame adopting another frame's scale
(
{:frame, :main, :x}, §4.3) had no JSON form, soChart.to_json/1raised on any design with an adoption, and the design couldn't be stored by a host or the builder's store. It now encodes as{"$adopted": ["main", "x"]}and reads back. A tagged object is never read as a node. - The default tick label prints a float in plain decimal (#354), never
1.0e3. - A compiled chart resolves a mark's paints and carries the design's defs (#356). A gradient fill was black on the canvas and unrenderable on the SVG layer.
- The binary canvas stream encodes
line,polyline,polygonandellipse(#290). The encoder silently dropped them, so an axis's ticks drew nothing on a binary canvas. - A contour ring always closes (#81, D-112). A crossing never sits on a grid corner, and a ring closes on its exact start.
compose/2no longer merges a whole-node variable into a:unionkey (#120, D-92). It could produce a struct with foreign keys.CrosshairHookmeasures the chart, not its container (#507). Its markers sat a few pixels off the series under an inline<svg>or<canvas>.- Cost:
- Building a path is linear in its commands; it was quadratic (#414).
- A scroll tick costs the strip and its neighbours, not the window (#333, #404, #410, #423, D-108).
- The incremental window keeps no closure, so a scroll's cost no longer doubles every frame (#406).
- The
:natural,:basis_closedand:cardinal_closedcurves are linear (D-114).
- Documented examples run (#117, #121, D-90, D-93). Every
iex>example underlib/is a doctest, and the three that were wrong are corrected.
0.1.0 — 2026-09-09
0.1.0 is the first tag. Until it, the only way to depend on this library was
branch: "main", so everything below has already reached anyone tracking that branch — the
sections are therefore written as they will read from the tag onwards: Added is the
surface a new consumer gets, and Changed and Fixed are what moved under a consumer
who was following main and now has a point to pin.
Pre-1.0. The surface can still move; a breaking change opens 0.2.0 rather than bending
the meaning of a patch.
Added
The declarative chart layer —
Visualize.Chart. A chart is a design: a plain map of atoms, numbers, strings andVisualize.Chart.Varplaceholders, with no functions and no structs in it, so a design can be stored, diffed, sent over the wire, versioned and edited by something other than code (D-57). The imperative pipeline —Scale→Shape→IR.Element→Render— is unchanged and remains the layer this one is written on; nothing about the chart layer is mandatory.Visualize.Chart.Schemais the design's grammar as data. Every key carries a facet (:data,:geometry,:channel,:binding,:style,:meta) and a merge rule, so a validator, an editor and a composition operator all read the same description instead of each carrying their own copy of it (D-58).Schema.describe/1is what the builder's UI is generated from.Visualize.Chart.Validatorchecks a design against the schema and reports by path —[:marks, 2, :channels, :y]— rather than by message, so an editor can put an error next to the field that caused it.- Frames own the scales. A frame declares named scales whose domains may be
:auto, realises them once from the whole bound column, never widens them afterwards, and renders its own furniture — axes, grid, legend, labels — with or without data (D-60). - Marks are the generators, named:
:line,:area,:band,:rule,:x_band,:percentile_band,:rose,:symbol,:arc,:path,:rect,:circle. A mark names the scale each channel family reads through (D-61, D-67), and takes its paint from the theme's series. - Transforms are pure steps over rows, run by the frame before its scales are
realised, so a transform can change what the domains infer from (D-62):
:filter,:bin,:stack,:sum,:sort,:tree,:cluster,:pack,:partition,:treemap,:chord,:sankey,:force,:contour,:density,:delaunay,:voronoi,:projection. - Styles and themes. A style node resolves to two outputs — literal attributes for
SVG, and CSS custom-property references for a themed page — and binds its field
references per element (D-63).
Visualize.Themecarries the slots; a slot renders asvar(--vis-…, <literal>), its literal always present as the fallback, so a page that ships no stylesheet still draws in colour (D-55). - Templates: typed source slots and
Visualize.Chart.Varvariables, bound byChart.apply/2. A variable's default lives in its declaration, and a stored design keeps what its author wrote rather than being rewritten with defaults (D-59). Chart.compile/2splits a chart into the regions that are static and drawn once and those that are dynamic and carry a closure, fixes each mark's render target at compilation, and gives the streaming window its own plot-area canvas (D-66).Chart.to_map/1,from_map/1,to_json/1,from_json/1— JSON carries what JSON cannot hold (dates, tuples, atoms) as tagged terms, and the round trip is the identity (D-57). JSON needs the optional:jasondependency; without it the two JSON functions return an error value rather than failing to compile.
The fragment algebra —
Visualize.Chart.Buildand the operators on it. A fragment is a partial design, and the algebra is what makes designs composable rather than merely storable.Visualize.Chart.Buildbuilds fragments through one function per schema node — a function per mark type, transform op and scale kind, generated from the schema so the builder cannot drift from the grammar it builds (D-79). Every option is a key of the node the function builds (D-78).Chart.compose/1,2merges fragments key by key under the schema's merge rules.Chart.stack/1,2is a second closed operation over the same values: a cascade, where a higher layer overrides a lower one and the result is itself a fragment, so a stack of stacks is a stack (D-81). Composition unions; a cascade overrides. They are different questions and now have different operators.- Element identity. A mark's or label's identity is its
id, an axis's is{scale, side}, and a matched element is replaced whole rather than deep-merged (D-82) — which is what lets a layer say "this one, replaced" without saying it by list position. Chart.explain/1reports provenance: which layer each key in the result came from, computed from the layers rather than carried in the fragment (D-83).Chart.free_vars/1reports the variables a design still needs, walking exactly where application walks; a variable bound to another variable is an error reported by path (D-84).extends:on a style derives it from a parent, flattened at the lookup rather than at write time, so a change to the parent reaches its children (D-80).
Visualize.Chart.Builder— an embeddable LiveComponent for building designs. Optional in every sense: it needs LiveView, which is an optional dependency, and it is a component the host mounts rather than a route the library owns. Its only output is one message to the host, which owns the route, the storage (Builder.Store) and the meaning of saving (D-85). The editor's nodes, controls and widgets are generated from the schema's types, and a composite value is read as a literal and never evaluated (D-86). The stack panel shows the layers — disabling a layer is not deleting it — the inspector isexplain/1rendered, and the parameters form isfree_vars/1rendered, editing the builder's own copy of the bindings (D-87, D-88). Import and export move a design as JSON.New marks:
Visualize.Shape.Band(a state timeline: one:rectper datum, index-aligned — D-26),Visualize.Shape.RuleandVisualize.Shape.XBand(annotations that take domain values and a scale, not pixels — D-27),Visualize.Shape.PercentileBand(a composition with a fixed output shape — D-28), andVisualize.Shape.Rose(Arcover a radial scale — D-31).New scale:
Visualize.Scale.Radial, an angle scale whose ticks divide the turn and whosenice/1is the identity, because there is no nicer boundary on a circle than the one you asked for (D-30).Interaction hooks —
Visualize.Hooks.ZoomandBrush(with one-axis selection helpers that work on the axes they are given — D-29),Resize, and the three canvas hooksCanvasChart,CanvasBinaryChartandCanvasIncrementalChart(D-40). New in this release:Tooltip, which reads the datum's own fields from data attributes the server wrote on the element (D-75);Crosshair, which snaps to an array the server wrote once and draws in an overlay it owns rather than in the patched subtree (D-76); andLegend, where an entry and its series path carry the samedata-seriesand toggling hides withdisplay(D-77). The pattern throughout: data attributes are the contract, and a hook draws outside the subtree LiveView patches.Tabular ingestion —
Visualize.Data.Table.rows/1. One door for tabular data: row lists, column maps, Nx tensors, Explorer data frames and anything else implementingTable.Reader(D-52). Every generator, compute and component reads its data through it (D-53), so a data frame is accepted wherever a list of maps is. The:tablepackage is optional, like Nx; without it lists, column maps and tensors still work and a struct source raises at the call.Themes and accessibility.
Visualize.Themewith a light and a dark palette and a generated stylesheet; responsive sizing as two halves —ResizeHookfor the round trip andviewBoxscaling for everything between (D-54); and a chart that names itself, with<title>,<desc>androle="img"on the root andaria-hiddenaxes (D-56).Ten preset chart components (
Visualize.Chart.Presets): line, bar, horizontal bar, pie, scatter, area, stacked bar, tree, treemap and sunburst — each a preset design plus an assign mapping, drawn by the chart layer into the same markup the hand-written components always produced (D-64), and byte-identical to their goldens.The specification ships with the package (
spec/), together withAPI_SURFACE.md, a generated golden that lists every public function the specification declares against every public functionlib/defines. A function inlib/with no row isunspecified; a row with no function isunimplemented; either fails CI. It is the contract a consumer holds the library to, so it ships.LICENSE— MIT — andpackage/0now declareslicenses: ["MIT"]. Before this the licence was unstated to anyone who received the package.
Changed
Visualize.Render.with_backend/2is removed (D-2). It set a backend in the process dictionary for the duration of a function, so any render inside that function silently used a backend chosen somewhere else, and restoring the previous value neededtry/after. A backend is now selected only by the:backendoption at the call site or byconfig :visualize, default_backend:. Code that rendered insidewith_backend/2must threadbackend:through instead.Shape.Stackreturns its series in key order, not in stacking order, and each series carriesindex, its position in the stack (D-22). A caller that indexed the returned list by stacking position must readindexinstead.:divergingand:wiggleare now ports of d3'sstackOffsetDivergingandstackOffsetWiggle; previously:divergingdid not accumulate negatives and:wigglewas:silhouetteunder another name.A collapsed domain maps to the range midpoint in every continuous scale, and nothing raises (D-49).
LinearandTimepreviously raisedArithmeticErroron a single-datum extent or an all-zero[0, 0]domain;PowerandSymlogreturned the range start. All five now return the midpoint, as d3 does — a single-datum chart draws its point in the middle of the axis. Callers that widened collapsed domains by hand no longer need to.:topaxis labels moved off the plot, and band ticks moved to the centre of the band (D-23). A:topaxis's labels shift by2·(tick_size_inner + tick_padding); every band axis's ticks shift by half a band. A caller who had addedbandwidth / 2throughoffset/2to centre ticks by hand is not double-shifted, but should drop the workaround.Negative zero is folded in the
dserialisers, so a coordinate that rounds to zero prints0and never-0(D-32), and in the projection golden, where the sign of zero is not a property of the maths (D-6). Path strings that differed only in a minus sign before a zero are now stable across refactors.One
dserialisation. There were two path serialisers producing different strings for the same path; there is now one, behind both the SVG bridge and the IR (D-12, D-34, D-71). The tree components' link paths therefore print in the comma-separated form (D-71, #79); goldens that recorded the old form were regenerated.Time ticks follow d3-time: boundary snapping on every interval, ratio-based interval choice, clamped month stepping and multi-year intervals (D-16). A time axis that previously produced unsnapped or wrongly-spaced ticks produces d3's.
Format.number/2groups the magnitude,Format.formatter/1is a parser for d3-format specifiers, andFormat.time/2is a one-pass strftime tokenizer (D-24, D-25) — where before each was an approximation.The scale protocol is a behaviour, and every scale stores lists (D-15).
Power,Symlog,Quantile,QuantizeandThresholdimplement it, soScale.apply/2,ticks,nice,invertandbandwidthwork uniformly across every scale type — which is what letsAxisstop special-casing scale kinds.Layout.Force's many-body force has no:theta(D-4). The option was accepted and ignored — there is no quadtree behind it — so it is removed rather than left inert. The force is exact, O(n²) per tick. Passing:thetais now a programmer error.Geo.Projection.clip_angle/2clips. The value was stored and never read, so orthographic globes drew the back of the world over the front (D-3).project/3now returnsnilbeyond the clip angle, and azimuthal projections take d3's per-type defaults. Callers that project points beyond the horizon now receiveniland must handle it;Geo.Pathdrops them.Phoenix is optional and stays optional (D-7, D-45).
Visualize.Components,Visualize.Components.Tree,Visualize.Chart.Builderand thePhoenix.HTML.Safeimplementations compile only whenPhoenix.Componentis loaded; a host without LiveView never fetches Phoenix. The same holds for Nx,:jasonand:table, and a consumer check runs on every pipeline to prove it.Geo.ProjectionandBackend.CanvasBinarywere split into family modules, which are@doc falseinternals rather than public API (D-9).The canvas binary format gained f32 path records (
path32,path_cubic32), which are the encoder's default because canvas coordinates are pixels (D-73), and a compact cubic-run record with no sub-opcodes, because that is what every curve generator emits (D-74). The decoder round-trips both.
Fixed
Visualize.Componentsnever rendered. The component layer called aScaleAPI that did not exist; every component raised. It was rewritten against the real API and is now held by render goldens (#43).Visualize.Components.Tree— tree, treemap and sunburst — was rewritten the same way and now honourscolorsandlabel(#44, D-46).Geo.Delaunaywas not a Delaunay triangulation. It is now Bowyer–Watson with an orientation-normalised in-circle test and half-edges (D-43), which also makesGeo.Voronoicorrect.The hierarchy, sankey and pack layouts are now ports of d3's.
Layout.Hierarchygainedpath/2andstratify/2; the tidy tree is Buchheim;PartitionandTreemapkeep zero-valued children (D-41);Sankeyhas d3's alignments and one link scale;Packis d3'spackSiblings/packEnclosewith a deterministic fallback (D-42).Projection rotation now inverts in reverse order, the collision force pushes both nodes rather than one, and every computed range has a step — the
0..-1empty-range class was swept out of the tree (D-44).Transverse Mercator
invert/3no longer raises. It raisedArithmeticErroraway from the central meridian; it is now d3's spherical inverse, returnsniloutside the hemisphere, and the forward projection clips (D-5, D-51). The projection golden records the inverse instead of excluding it.LineandAreabreak into subpaths at undefined data instead of drawing through the gap (D-18);Curve.natural/1is the natural cubic spline, solved rather than approximated (D-19);Curve.basis/1is d3'scurveBasis, which previously skipped a B-spline segment and started and ended in the wrong place (D-33); the step curves emit d3-identical paths (D-8);Line.x/1andLine.y/1accept constants, andLineNxhonours:curve(D-20).Shape.Arcappliescorner_radiusandpad_angle(D-21). Both were accepted and ignored.Band, quantize, colour, ordinal, linear, log and symlog tick defects, and a crash in
Data.ticks/3(D-17, #24).IR.Transform.scale/2's pipeline form raisedFunctionClauseErrorbecause a guard ordering made its second clause unreachable (D-1).IR.Path.transform/2now takes a full affine matrix (D-35).SVG.Element.from_ir/1rendersview_boxasviewBox— attribute names go through one map, so casing cannot differ between the two bridges (D-11, D-34).Backend.Canvasemits executable commands and group styles; theCanvasIncrementalandIncrementalcontracts were made to match the code;Benchmarkmeasures elapsed time with its own clock (D-37, D-38, D-39).Hooks.js_code/0emitted every hook twice, as duplicate named exports, which is a syntax error in a JavaScript module (D-48); andBrushHookleaked its listeners, because it removed handlers it had never bound — it now stores the bound handlers and removes those (D-47).Contour.Density.compute/2took minutes on its default grid. The grid is now an indexed structure and the ring tracing takes each segment once; a list read by index was the whole cost (D-72).Shape.LineNxwarned at compile time in a consumer without Nx — nine warnings, not the one first reported. Both Nx-calling modules declare@compile {:no_warn_undefined, Nx}and a consumer check fails on anywarning:line from the library's compile (D-50).Data.range/3and a set of dead clauses across the tree; the force simulation's timer discipline — tagged ticks, restart keeps the state, alpha is clamped — under an ExTLA model that is checked in CI (D-14).