Arrows
An arrow is a curve between two ends. It stores the two ends and one number for the curve. Like a line, it refits its frame after every edit. The arrowheads are fields of the drawable, so no other kind can carry one.
The drawable
An arrow is { kind: "arrow", start, end, bend, startHead, endHead }. start and end are LocalPoints in frame-local units. bend is a signed distance, also in local units.
The straight segment from start to end is the chord. bend is the distance from the chord’s midpoint to the curve’s midpoint, measured perpendicular to the chord. A bend of 0 is a straight arrow.
A positive bend puts the curve’s midpoint to the left of start → end. Left is the side a traveller who walks from start to end sees on their left, on screen. Page and local space are y-down, so the left normal of a chord c is (c.y, -c.x) / |c|. For example, take start = (0, 0) and end = (100, 0), a chord that points right. Its left normal is (0, -1), which points up on screen. A bend of 50 puts the curve’s midpoint at (50, -50), above the chord. A bend of -50 puts it at (50, 50), below.
startHead and endHead are an Arrowhead: "none", "arrow", "triangle", "dot" or "bar". The package stores them and draws nothing. Every edit keeps them as they were.
create(frame) returns a straight arrow from (0, 0) to (width, height), with no head at the start and an "arrow" head at the end.
The curve
The curve is the circular arc through start, the curve’s midpoint and end. With a half chord h and s = |bend|:
- The radius is
(h² + s²) / (2s). - The arc sweeps
4 · atan(bend / h)radians fromstarttoend. Abendlarger thanhsweeps more than a half circle.
The arrow is straight when the chord has length 0, or when |bend| is below 1e-6 times the chord length. At that limit the radius is about 250,000 times the half chord, so it stays finite and well inside floating-point range. Below it the curve is closer to its chord than any screen can show.
Geometry
geometry flattens the arc to an open polyline, with closed: false. The outline starts exactly at start and ends exactly at end. It uses the same chord-error rule as an ellipse: no straight segment strays more than 0.5 units from the circle. The rule gives a segment count for the whole circle, and the arc takes its share, |sweep| / 2π of it. The count is rounded up to an even number, at least 2, so the curve’s midpoint is always a vertex of the outline. A straight arrow is the two ends.
A zero-length arrow, with start equal to end, has an outline of two coincident points. Its bend has no direction to act in, so the outline is a dot that hitTest finds within the tolerance. Nothing on the arrow’s path produces NaN.
The fit invariant
An arrow follows the same rule as the points drawables. After any edit, the frame is the bounding box of the flattened polyline, not only of the two ends. A curved arrow’s frame holds its bulge, so selection bounds and hit testing match what is drawn. The refit keeps the frame’s rotation, and every point keeps its page position. The box is the polyline’s box, so it can be a little smaller than the true circle’s box between two vertices.
Handles
handles(frame, drawable) returns three handles in page space, in this order:
- A
terminalhandle withterminal: "start", onstart. - A
terminalhandle withterminal: "end", onend. - A
bendhandle on the curve’s midpoint. On a straight arrow, that is the chord’s midpoint.
dragHandle(drawable, drag) converts the pointer to the local space of drag.frame and then:
- Terminal: moves that end to the pointer and keeps
bend. The curve keeps its sagitta over the new chord. - Bend: sets
bendto the pointer’s signed distance from the chord’s line, left positive. The line is infinite, so a pointer past either end still sets a bend. A zero-length arrow has no line, so its bend stays0. - Vertex: an arrow has no vertices, so it returns
drag.frameand the drawable unchanged.
Every drag refits the frame, keeps its rotation and keeps the page position of each end that did not move.
import { adapterFor, pagePoint, radians } from "@draw/shapes";import type { AdapterEnv, Fitted, Frame, Handle } from "@draw/shapes";
const env: AdapterEnv = { measureText: (line, font) => line.length * font.size * 0.6 };
const frame: Frame = { x: 100, y: 100, width: 200, height: 0, rotation: radians(0) };
const adapter = adapterFor("arrow");
const arrow = adapter.create(frame, env);
export const handles: readonly Handle[] = adapter.handles(frame, arrow);
export const bent: readonly Fitted<"arrow">[] = handles .filter((handle) => handle.kind === "bend") .map((handle) => adapter.dragHandle(arrow, { frame, handle, pointer: pagePoint(150, 60) }, env));The straight arrow runs from (100, 100) to (300, 100), and its bend handle sits on the chord at (200, 100). The pointer is at (150, 60), 40 units above the chord’s line, so bend becomes 40. The pointer is not over the midpoint, but only its distance from the line counts. The curve now bulges up to y = 60, so the frame grows to a 200 by 40 box at y = 60. Both ends keep their page positions, at local y = 40.
Resizing and flips
resize(drawable, change) maps three points through the change’s origin and scale: start, end and the curve’s midpoint. The new bend is the signed distance of the mapped midpoint from the new chord. The arrow then refits against change.frame.
A flip is not stored. It mirrors the curve. A flip on one axis reverses the direction of travel relative to the bulge, so the sign of bend changes by itself. A flip on both axes keeps it.
A one-axis resize does not keep a circle’s proportions. The mapped midpoint still sets the bend, and the result is the circular arc through the three mapped points.