Skip to content

Curve ​

The Curve family holds the ways a path can run through its points. A curve is the value of the curve option of line and ribbon, of time.transition() and Animation.tween(), and of the method option of interpolate().

js
gf.chart(drivingShifts, { axes: true })
  .flow(gf.scatter({ by: "year", x: "miles", y: "gas" }))
  .mark(gf.circle({ r: 2.5, fill: "white", stroke: "black", strokeWidth: 1 }))
  .layer(gf.line({ along: "year", curve: gf.Curve.monotone() }))
  .render(root, { w: 400, h: 300 });

Signature ​

ts
Curve.linear;
Curve.step;
Curve.monotone;
Curve.smooth;
Curve.catmullRom;
Curve.bezier;
Curve.orthogonal; // or Curve.orthogonal({ bend?: "auto" })
Curve.arc; // or Curve.arc({ direction?: "up" | "down" })
Curve.perfectArrows; // or Curve.perfectArrows({ bow?, stretch?, stretchMin?, stretchMax?, padStart?, padEnd?, flip?, straights? })

No curve has a required option, so each member is a curve as it is, and calling it sets options: Curve.monotone and Curve.monotone() are the same curve, and Curve.arc({ direction: "down" }) sets the arc's direction. This holds in every strategy family: a kind with no required option is a value, and a kind with one is a call only, like Bin.step(size). A curve is a plain object, { kind, ...params }, of type Curve.Curve, for example { kind: "arc", direction: "down" }. The same object is the curve's wire form. (#1145 moves the examples to the bare spelling.)

The family is also a module of its own:

ts
import { Curve } from "gofish-graphics";
import { monotone } from "gofish-graphics/curve"; // the same value as Curve.monotone

Members ​

MemberWhat it drawsRead over time
Curve.step()Each value holds until the next point, and then jumps.yes
Curve.linear()Straight segments from point to point.yes
Curve.monotone()A smooth curve that turns exactly on the points.yes
Curve.smooth()Rounder peaks that can pass a little beyond their points.yes
Curve.catmullRom()A centripetal Catmull-Rom spline through the points on the screen.no
Curve.bezier()A cubic bezier between each pair of points (d3's linkVertical).no
Curve.orthogonal()A right-angle elbow that bends at the midpoint of the connector's axis.no
Curve.arc()A half circle through both points.no
Curve.perfectArrows()The arc between two boxes that the perfect-arrows library draws.no

A curve that is read over time can also move a mark: time.transition(), Animation.tween() and interpolate() take only those four.

When a line or ribbon has no curve, it looks at the connected points. If they share a continuous connection axis, it uses Curve.monotone(). Otherwise a line uses Curve.linear() and a ribbon uses Curve.bezier(). time.transition(), Animation.tween() and interpolate() default to Curve.monotone().

Curves through data ​

Four curves read a run of values over the field that orders it, such as the years of a line chart. From the least to the most smooth, they are Curve.step(), Curve.linear(), Curve.monotone() and Curve.smooth(). Because they read the same run the same way, a moving mark and a line through the same points can follow the same curve.

A few terms help compare them. A curve is C0 when it has no breaks, and C1 when its direction also never changes suddenly (it has no corners). A curve overshoots when, between two neighboring points, it goes above the higher one or below the lower one. A curve is local when changing one value changes the curve only near that point.

CurveAlgorithmContinuityLocalNever overshoots
Curve.step()step-after, like d3's curveStepAfternot continuousyesyes
Curve.linear()straight linesC0yesyes
Curve.monotone()Steffen (1990), the same curve as d3's curveMonotoneXC1yesyes
Curve.smooth()modified Akima, also called makima (Moler 2019), as in MATLAB and SciPyC1yesno

Curve.step() holds every value that depends on the field that orders the line until the next point's value of that field, and then jumps. It never holds the field itself. On a line chart over years, the year is the x axis, so the line moves along x while y holds, and the jump is a vertical riser: the staircase of d3's curveStepAfter and Vega-Lite's interpolate: "step-after". On a connected scatterplot over years, x and y both depend on the year, so both hold, and the jump is a straight line from one point to the next. It looks like Curve.linear(), but all of the time is spent at the points, so a line drawn in over time jumps from one point to the next at the moment the next year arrives. This differs on purpose from d3's step curves, which always draw a horizontal step and then a vertical one on the screen, whatever the axes mean. On a ribbon, it steps both edges, as Vega-Lite's stepped area does.

Curve.monotone() is piecewise monotone. Between two neighboring points, each coordinate only rises or only falls, so the curve never goes past either point. It does not make the whole line monotone. The line still turns where the data turns, and the peak sits exactly on the data point. For a path in x and y, such as a connected scatterplot, this holds for x and y separately, over the field that orders the line. It is the same curve as d3's curveMonotoneX and Vega-Lite's interpolate: "monotone".

Curve.smooth() lets a peak round off a little past its point. A run of three or more equal values stays exactly flat. A single flat step between a rise and a fall, such as two equal peak values, can bow a little.

A line drawn with one of these curves takes the knots of its curve from the data when the data has a value that orders the line. (The knots are the positions along the curve where it passes through each point.) line uses the first of these that it finds:

  • The values of the field the line runs along, when they are numbers that only go up or only go down along the line. This is the field along names, or the field of the tier the line was inferred to run along, e.g., the years of a connected scatterplot. A line through the keyframes of a time.sequence uses the keyframes' time values.
  • The points' positions on the connection axis, when that axis is continuous and the points are in order along it, e.g., a line chart over x.
  • The distances between the points on the screen, when neither of the above applies. These are centripetal knots. Two points at the same spot, up to rounding, are one point: the curve drops the repeat, as d3 does.

A ribbon picks its knots the same way, and both edges of the band use the same knots. Because the knots come from the data, a line and a time.transition() through the same points follow the same curve when they use the same curve.

Curves on the screen ​

Curve.catmullRom() draws a centripetal Catmull-Rom spline through the points on the screen, as d3's curveCatmullRom does. Its knots are always the distances between the points on the screen, whatever field orders the line. It can overshoot between two points. It is not read over time: a time.transition() along the same points follows one of the curves above, so its moving mark can sit slightly off a Catmull-Rom line.

Curve.bezier(), Curve.orthogonal(), Curve.arc() and Curve.perfectArrows() route each pair of neighboring points on their own. The Curve.orthogonal() elbow bends at the midpoint of the connector's dir axis; Curve.orthogonal({ bend: "auto" }) infers the bend axis from the endpoint geometry instead, for layouts with no single growth axis. Curve.arc() bulges up by default; { direction: "down" } flips it. A line threaded through the keyframes of a time.sequence cannot use Curve.orthogonal(), Curve.arc() or Curve.perfectArrows().