Paths, shapes and fields

matchPaths() and shape() return SVG path data for a track or a computed property, and distribute() and falloff() place many points and give each one a weight from a field that moves with the scene's time.

A path is a value, bound with data-bind-attr="{ d: name }" or written inside path("…") on a CSS property. A count such as sides does not interpolate, so to morph between counts give both shapes to matchPaths(), whose paths need the same number of subpaths. A transform track on an SVG shape replaces its transform attribute, so put the placement on a wrapping <g>.

Paths and shapes

These functions return SVG path data, so a path is a value a track or a computed property gives, bound with data-bind-attr="{ d: name }" or written inside path("…") on a CSS property.

matchPaths(paths, options)

matchPaths(paths, options) converts paths with different commands into paths a text track can move between. paths is an array of two or more SVG paths, each a d string or a path("…") value.

const { scene, matchPaths } = wildflower.kinetic;
const SQUARE = 'M -100 -100 H 100 V 100 H -100 Z';
const CIRCLE = 'M 0 -120 A 120 120 0 1 1 0 120 A 120 120 0 1 1 0 -120 Z';

scene('logo', {
    keyframes: {
        shape: { at: [0, 1.2], to: matchPaths([SQUARE, CIRCLE]), ease: 'power2.inOut' }
    }
});
// <path data-bind-attr="{ d: shape }"></path>
The faint paths are the matched pair with a dot at each segment's end, and a low segment count or align off twists the morph, which a click pauses.

The result is an array of the same length, each path in the form it came in, made of absolute cubic Béziers with the same commands and number of segments. It is keyframe values, so the morph is a track like any other, and interpolate() takes it too.

OptionDefaultMeaning
segments8The fewest segments each subpath gets, a whole number, 1 or more. 8 is the fewest that keeps a square to a circle from twisting.
aligntrueEach closed subpath after the first path's is reversed when it runs the other way, and then started at its point nearest the first path's start. false keeps every path as given.
  • Any SVG path data is accepted, with M, L, H, V, C, S, Q, T, A and Z, absolute or relative, repeated commands left implicit, and numbers and flags written together.
  • Each segment becomes a cubic. A line gets its control points at its thirds, a quadratic is raised to a cubic, and an arc is converted by the SVG 2 implementation notes and split into pieces of 90 degrees at most. A Z whose last point is not the subpath's first closes it with a line.
  • Subpaths are matched in order, so the paths need the same number of them. Each subpath gets the most segments any of the paths has there, and at least segments.
  • Each further cut goes to the segment whose pieces are longest, and a segment cut into k pieces is cut into k equal spans of its curve's parameter. Polygons morph evenly at the least common multiple of their corner counts, such as 24 for a triangle and an octagon.
  • A Z is written where every path closes the subpath, and an open subpath is never reversed or restarted.
  • Numbers have at most four decimals. The browser's getTotalLength() of each result is within 0.05 px of its input's.
  • Fewer than two paths, an empty path, path data the grammar rejects, a different number of subpaths, or a segments that is not a whole number of 1 or more throws (KN-100). For path data the grammar rejects, the message gives the path, and the development build's message gives the index of the first character it rejects.

shape(kind, options)

shape(kind, options) returns the path data of a polygon, a star, a sector or an arrow, with every option a number, or a point for an arrow, so a track or a computed property can drive any of them.

KindOptions (defaults)Shape
'polygon'sides (a whole number, 3 or more), radius, cx (0), cy (0), rotate (0)A regular polygon around (cx, cy), its first vertex pointing up at rotate 0.
'star'points (a whole number, 2 or more), outer, inner, cx, cy, rotatepoints vertices at outer from the center, with one at inner between each two, the first pointing up at rotate 0.
'sector'radius, inner (0), from (0), to, cx, cyA pie slice from from to to, or a ring segment when inner is above 0.
'arrow'from [x, y], to [x, y], width, headA block arrow from from to its point at to, with a shaft width wide and a head head long and twice that wide.
The faint circles are outer and inner, and inner stops at 40, the least outer can be, so the star never turns over.
The faint circle is the radius, through every vertex.
const { scene, shape } = wildflower.kinetic;

scene('gauge', {
    keyframes: { sweep: { at: [0, 2], to: [0, 270], ease: 'power2.out' } },
    computed: {
        pie() { return shape('sector', { radius: 120, to: this.sweep, cx: 160, cy: 160 }); }
    }
});
// <path data-bind-attr="{ d: pie }"></path>
The faint circles are radius and inner, and inner stays below radius, since a sector's inner at or above it throws.
The call draws the arrow along x with the faint dots at from and to, and angle turns the group it is drawn in.
  • Angles are in degrees, clockwise from up, and numbers have at most four decimals.
  • A sector's to below its from runs anticlockwise. A sector whose to equals its from returns an empty string, so nothing is drawn, with no line from the center. A sweep of 360 degrees or more is drawn as two arcs so the circle closes, and a whole ring keeps its hole.
  • An arrow shorter than its head is drawn as the head alone, as long as the arrow, and an arrow whose from equals its to returns an empty string.
  • A star's inner above its outer is not checked, and draws the star turned over.
  • A count does not interpolate, so a track from 3 to 8 sides passes values such as 3.5, which throw, and with round: true the shape jumps from one count to the next. To morph between counts, give both shapes to matchPaths() and put its result in a track.

An unknown kind, a missing option, a value that is not a finite number, a sides or points that is not a whole number, a radius, outer, inner, width or head below 0, or a sector's inner not below its radius throws (KN-100).

trimPath(d, from, to, options)

trimPath() returns the stretch of a path between two fractions of it, from 0 at its start to 1 at its end, as path data. The dash recipe draws a stroke partway and gives no shape, while this is the shape itself, so it can fill, feed a canvas or three.js, or be measured.

const { trimPath, ramp } = wildflower.kinetic;

trace() { return trimPath(FLIGHT_D, 0, ramp(this.local, 1, 3)); }       // the line drawn from its start
tail()  { return trimPath(ROUTE_D, this.u - 0.08, this.u); }             // the stretch just behind a mover
flow()  { return trimPath([TOP_D, BOTTOM_D], 0, this.p); }               // a band between two edges, closed
// <path data-bind-attr="{ d: trace }"></path>
OptionDefaultSets
by'length'What a fraction measures: of the whole length, or with 'segment' each segment an equal share, with the curve's own parameter inside it.
The faint line is the whole path and the accent is the stretch from from to to. The dots are the joins between the curve's three segments: a long arch, 80% of the length, and two short lines. By length, to at 0.6 stops partway along the arch; by segment each segment is a third, so it runs past the arch onto the first line. [TOP, BOTTOM] fills the band between two edges.
  • from and to are clamped to 0 and 1, and a from at or past to returns an empty string, so nothing is drawn.
  • The stretch is written as absolute cubics, with a new move wherever it crosses into another subpath. A closed subpath taken whole keeps its Z, and one cut partway leaves it off.
  • By segment, a path written as one segment for each even step of time is cut at a time, so a trace can end at a mover whose speed changes, where a fraction of the length would run ahead of it or behind it.
  • For a band, [top, bottom] are two edges of one subpath each, running from the band's start to its end, and the result is the top's stretch, the bottom's stretch run back, and a Z. A bottom edge written the other way, which starts nearer the top's end, is turned round first, so it draws the same band.
  • A path("…") value comes back in the same form, for clipPath or offsetPath. Trimmed to nothing, it is a lone move such as path("M 0 0"), which draws nothing as a d and hides everything as a clipPath, where '' would take the style off.
  • pathLength(d) gives the path's length in its own units, from the same measure, so a distance along it becomes a fraction: trimPath(d, 0, 150 / pathLength(d)) is the first 150 units.

d that is not path data, a from or to that is not a finite number, an unknown by, or band edges that are not two paths of one subpath each throws (KN-100).

Transform tracks on SVG elements

A rotate or scale track on an SVG element turns or sizes it about the origin of the SVG's viewBox. transform-box: fill-box; transform-origin: center on the element moves the origin to the center of its own box. transform-box is CSS's own property for the box that transform-origin is measured in: fill-box is the box around the shape's geometry, the area its fill covers with the stroke left out, and view-box, the default for SVG, is the SVG's own viewport.

A transform track writes the CSS transform property, which replaces the element's transform attribute, so a shape placed with transform="translate(…)" loses its place. Put the placement on a wrapping <g> and the track on the shape inside it.

<g transform="translate(400 360)">
    <path style="transform-box: fill-box; transform-origin: center"
          data-bind-attr="{ d: shape }" data-bind-style="turn"></path>
</g>
With fill-box on, the square turns about its center, the right faint dot, and off it swings round the viewBox origin, the left one, and a click pauses it.

Distributions and falloffs

Two more pure functions move many things as one. distribute() places n points, and falloff() gives each point a weight from a field the scene moves with its time. A computed then turns the weight into a scale, an opacity, a color mix or a delay, such as styleAt(this.local - (1 - w) * 0.6, tracks).

distribute(kind, n, options)

distribute(kind, n, options) returns n points, each { i, x, y, heading, u }, placed on the line, circle, grid, spiral, scatter or path its kind names.

KindOptionsPoints
'line'from and to, both [x, y]Evenly from from to to, both ends included.
'circle'cx, cy and radius, all three required; from (0) and to (from + 360), in degrees clockwise from up as shape() takes themA full turn spaced 360 / n, or an arc with a point at each end.
'grid'columns, cell as px or [width, height], cx, cyCentered on cx and cy, numbered row by row as stagger(i, n, { grid }) numbers its cells, with col and row on each point.
'spiral'radius, cx, cyA sunflower by the golden angle, with the same area for each point, the points facing outward.
'scatter'width, height, cx, cy, seed (1)Spread evenly by best candidate over a width by height area centered on cx and cy, the same for the same seed.
'path'd, SVG path dataEvenly by length along the path, with both ends of an open path, and n even steps round a closed one.
Each kind shows the options it takes.
  • heading is the direction of travel in degrees for rotate(), 0 pointing right and 90 down, as offset-rotate: auto turns an element. u runs from 0 at the first point to 1 at the last.
  • cx and cy are 0 when left out, except on a circle.
  • A grid's short last row starts at the grid's left edge, so a grid with fewer points than columns sits left of cx.
  • A spiral's point i is at radius * Math.sqrt((i + 0.5) / n), so a lone point sits off the center.
  • A scatter's search and a path's measure are kept for their arguments, so a call on every seek costs the arithmetic alone, and every call returns new objects.

An unknown kind, an n that is not a whole number of 0 or more, or a bad option throws (KN-100), with the call and the option in its where.

falloff(kind, point, options)

falloff(kind, point, options) gives a point, { x, y } or [x, y], a weight from 0 to 1.

KindOptionsWeight
'radial'x, y, radius, inner (0)1 inside inner, falling to 0 at radius.
'linear'x, y, angle, widthAn edge through x, y heading toward angle: 1 behind it, falling to 0 over width ahead.
'band'x, y, angle, width1 on the line through x, y across angle, 0 at width either side.
'ring'x, y, radius, width1 at radius from x, y, 0 at width either side.
'shape'd, x (0), y (0), scale (1), width (0), rule ('nonzero' or 'evenodd')1 inside the path data d, moved by x and y and sized by scale, falling to 0 at width px outside its edge.
Each dot is sized by its weight, x moves the field, and the last knob is radius or width as the kind takes.

Every kind takes ease, which shapes the weight, and invert. A moving linear edge or band reveals or passes through a field of points, a growing ring is a ripple, and a shape is a logo made of them.

const { distribute, falloff, interpolate } = wildflower.kinetic;

dots() {
    const edge = interpolate(this.local, [0, 3], [-200, 1480]);
    return distribute('spiral', 400, { cx: 640, cy: 360, radius: 320 }).map((p) => {
        const w = falloff('linear', p, { x: edge, y: 360, angle: 0, width: 240, ease: 'sine.inOut' });
        return { id: p.i, style: { transform: `translate(${p.x}px, ${p.y}px) scale(${0.4 + 0.8 * w})` } };
    });
}

At width 0 a point exactly on a shape's edge reads 1 on some edges and 0 on others, so a point grid along the outline needs a width above 0.

An unknown kind, a point without x and y, or a bad option throws (KN-100), with the call and the option in its where.