npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@altpsyche/maths

v3.0.1

Published

The mathematics AltPsyche's figures are drawn from: vectors, matrices, curves, and a value walked over time.

Readme

@altpsyche/maths

The mathematics behind the figures on altpsyche.dev.

The model

A figure is a description of a picture over time. It carries an extent and a scene. The extent is a width and a height in the figure's own units. The scene is a tree of nodes holding paths, text, transforms and styles. A figure has no canvas, no clock and no state.

Evaluating a figure at time t yields a flat array of marks. A mark is one drawn item: an outline with a fill, a stroke, or both, or a piece of text. Its geometry is expressed in figure units with every transform already applied, and its style is resolved rather than inherited. A mark holds no reference to an output device.

marksAt(figure, t): readonly Mark[]

marksAt is a pure function of t. Two evaluations at one time produce identical arrays. A page playing forward, a reader dragging a scrub bar backward and a recorder stepping at a fixed rate therefore read one figure.

A painter consumes marks. viewMatrix builds the single affine transform taking figure units to a surface of a given size. It inverts the y axis, since a figure counts upward and both painters count downward from the top. No conversion to device units occurs anywhere else.

The figure above is evaluated at t = 7.86 s of a 10.25 s duration. Its extent is 10.8 by 6 units and its marks number 186 there, counting the inset it draws.

A figure is also data. Every part of one has a written form: a node is a record of a kind and its parameters, a parameter a track drives is an expression, and a timeline is its spans. writeFigure hands the whole of it back as the text of a file and readFigure reads that text into a figure, so a picture crosses a machine rather than a process. The four figures on this page are committed as files beside their pictures, in demos/tangent.figure.json, demos/boolean.figure.json, demos/rotate.figure.json and demos/surface.figure.json, and a gate reads each one back and holds its marks against what the demo draws.

Install

npm install @altpsyche/maths

Node 20 or newer, ESM, sideEffects: false. MathJax is a runtime dependency reached by a dynamic import inside typesetElement, so a figure containing no equations never loads its 41 MB. @altpsyche/engine is a peer dependency, which a package manager installs alongside this one: the vectors and matrices in space are imported from the door it declares for its arithmetic, which is one file and no renderer, and the GPU painter reaches the rest of it by a dynamic import of its own.

A complete figure

import { Timeline, circle, colourFrom, draw, group, marksAt, shape, svgMarkup, vec2, viewAt } from '@altpsyche/maths';

const figure = {
  extent: { width: 16, height: 9 },
  still: 1,
  scene: group('fig', [shape('ring', circle(vec2(0, 0), 3), { stroke: { colour: colourFrom('#fff'), width: 0.05 } })]),
  timeline: Timeline.empty().play(draw('fig/ring'), 1),
};

svgMarkup(marksAt(figure, 0.5), viewAt(figure, 0.5, 640, 360), 640, 360);

At t = 0.5 the timeline has applied draw at half its span, and the mark carries half the ring's arc length. svgMarkup returns a complete SVG document as a string, with a view box and no width or height of its own.

The frame

A frame expression is the width, the height, the aspect or the centre of the frame, read while the figure is drawn. A mark whose place is built from one moves when the frame changes shape, and a mark placed in the figure's own units does not. The frame is the extent the figure declares resolved at the aspect being drawn, never the extent a view move has left, since a view that follows a mark reads the marks and a mark reading that extent would ask for what is being built. matchingAspect gives a figure a different extent at each shape it is drawn in.

Geometry

All geometry is cubic Bézier. A path is a sequence of subpaths; a subpath is a start point followed by cubic segments, closed or open. A segment stores its two control points and its endpoint and not its start, which is why the functions evaluating one take that start as an argument.

circle and arc emit segments spanning at most a quarter turn. For a segment of angle θ the control distance is (4/3)·tan(θ/4). That bounds the radial error at 2.7 × 10⁻⁴ times the radius, and the suite holds the drawn edge between 2.6 × 10⁻⁴ and 2.8 × 10⁻⁴. splitCurve subdivides by de Casteljau's construction and pathFromData reads the elliptical arc form of an SVG d attribute by the conversion the specification itself gives.

unionOf, intersectionOf and differenceOf operate on closed loops of cubics. Crossings are located per segment pair and refined by Newton's method, the surviving pieces are stitched, and the result may contain a hole neither operand had. When the kept pieces fail to close, the call throws and the message states the piece count and the distance between the two open ends. Proximity is a distance in figure units, TOLERANCE = 1e-6 by default, rather than a fraction of anything.

areaOf returns the signed area, positive for anticlockwise winding, summed over subpaths. containsPoint flattens to polylines and applies the nonzero winding rule. lengthOf and pointAlong measure by arc length, and lengthOf reads six parts in a hundred million short of the truth.

Graphs

A scale is a pair of intervals: the numbers an axis counts through, and where those numbers land in figure units. coordsOf pairs two scales, and the mapping is a value the caller holds rather than state read back out of a drawn group.

plot samples a function and emits one Hermite cubic per interval. The control points sit a third of the way along in x and carry the sample's own slope, so the curve passes through both samples at both slopes. slopeOf reads the slope off that curve's own cubics, which carry a quadratic with nothing left over, so a parabola reads exactly rather than to a difference's O(h²). tangentAt clips the tangent line to the graph analytically rather than by sampling, since a line crosses each edge once.

streamlineOf integrates a field by fourth-order Runge-Kutta with a step in arc length rather than in time, which keeps the points evenly spaced. Halving the step divides the error along the curve by 15.1 and then 15.6, against the factor of 16 the order predicts.

Three forms draw what no function of x describes. parametric samples a function of one parameter and joins the samples by the same Hermite construction, cut where the curve leaves the graph across the width as well as the height; a unit circle at 96 samples reads within 4.3 × 10⁻⁷ of the true radius, where the four cubic quarters circle writes leave it 2.7 × 10⁻⁴ out. polar is that call under the map from a radius and an angle to a place. implicit finds the curve where a function of two numbers reaches a level, by marching squares: crossings are bisected on the cell edge rather than interpolated along it, an ambiguous cell is resolved by the value at its middle, and each place leaves along the gradient turned a quarter turn, which holds a unit circle within 2.3 × 10⁻⁷ of the true radius at 64 cells. An implicit curve is the one curve here whose count of places the figure does not fix, so it cannot be a morph's source.

Space

sphere3, cube3, cylinder3 and torus3 are cells over a parametrisation run in the order that faces every cell away from the solid, each also available as cells for a scene to sort among its own. A flat cell falls inside a sphere's true radius by 417.5, 106.4 and 26.7 parts in ten thousand at 12, 24 and 48 steps. curveOf3 reads a curve in space from one parameter, which is the third point producer beside sectionOf and streamlineOf. curvePieces3 cuts that curve into one entry per step, so a helix round a cylinder is sorted against the cylinder's own cells: all 300 of them stand between the first piece of the helix and the last, where a curve sorted whole is one mark at one depth and is painted entirely in front of the solid or entirely behind it.

camera3 holds an eye, a target, an up vector, a view matrix and a projection. perspective and orthographic supply the projection; the orthographic case is a scale rather than a divide and therefore has no near plane. Every builder that works in space projects to figure units and returns the same node types a graph returns, so one animation reaches both.

scene3 orders children back to front by the depth of their sample points, which is the painter's algorithm. surface3 cuts a surface into four-cornered cells, so two surfaces sort against each other rather than as two groups. The sort is stable, so pieces at equal depth hold the order the author gave and a picture does not flicker between frames. sectionOf returns the runs of points where a plane cuts a parametric surface, closing a run whose ends meet.

A sort alone leaves two cases with no right answer: pieces that pierce each other, and overlaps that run in a ring. So a mark carries a depth as well. Mark.depth is three numbers giving how far the mark stands from the eye as a function of where on the page it is drawn, and of two marks over one point the smaller number is the nearer. Three numbers are exact for a flat piece of the world, which is what a cell, a face and a segment each are. The builders in space fit them; a flat figure carries none, and a mark carrying none clears the depths before it, so a caption drawn after a surface covers it.

Time and motion

An animation maps marks and a fraction of a span to marks. Twenty-one of them are supplied, among them draw, fadeIn, moveAlong, rotate, applyMatrix, morph, morphEquation, which pairs the glyphs of two typeset expressions and moves only the difference, and morphGroup, which pairs two groups of marks by name and walks each pair in geometry and in style. Every animation is the identity at the start of its span. Every mark it introduces exists at every fraction, at zero opacity where it is not yet visible, so a frame-to-frame comparison never reports an arrival.

Timeline sequences animations by play, together and stagger, each span carrying its own easing curve. Timeline.at applies a finished span in full and an unstarted one at zero, making the timeline a function of time rather than a record of what has played. Tracks are the second source of values: sampleTrack reads a keyed value at a time, holding the nearest key outside the keyed range.

framesOf walks a figure at a fixed rate or count and yields one frame at a time. A frame carries its index, its time, its marks and the view matrix built at that same time. The walk stops strictly before the duration, so a looping figure never emits its first frame twice.

Painters

svgMarkup returns a document as a string and svgElements returns the elements as data. paintSvg replaces the children of an element already in a document, and paintCanvas draws into a two-dimensional context. Coordinates are written to three decimal places. That is finer than any screen or encoder resolves, and coarse enough that the last bits of a double never reach the output. One test paints a single mark list both ways and holds the two to the same geometry within a thousandth of a pixel, and to the same style exactly.

The two flat painters meet the depths by cutting. depthOrder cuts each mark where its depth crosses another's, which is along a straight line on the page, and paints the pieces furthest first. The GPU painter keeps a depth attachment and lets the card compare at every pixel, which is what two surfaces passing through each other need.

A figure may name the painters that can draw it. Figure.painters is a list of names and PAINTER_NAMES holds the three: svg, canvas and gpu. Left out, all three may. The refusal is at marksAt, since a painter is handed marks and never sees the figure they came from.

Recording

A recording is a figure walked at a fixed rate and encoded into a video file. recordFigure walks the figure, paints each frame onto the sink's own canvas and hands the frame over. videoSink is a sink that encodes: it loads mediabunny inside the call, so a consumer who never records never loads an encoder.

const canvas = document.createElement('canvas');
canvas.width = 1080;
canvas.height = 600;

const sink = await videoSink(canvas, { fps: 30, format: 'mp4', codec: 'avc' });
const { frames, seconds, output } = await recordFigure(figure, sink, {
  fps: 30,
  width: 1080,
  height: 600,
  background: colourFrom('#ffffff'),
});

output is the finished file as bytes. The walk is frameTimesOf, the same one the strips are drawn from, so a recording holds the frames the rest of the package counts. A frame lasts exactly one over the rate, and the walk stops strictly before the end, since the frame at the duration of a figure that loops is its own first frame.

A recording runs for the figure's own length unless seconds asks for another. Past the end, a figure that declares itself a loop is read at the remainder and a figure that does not holds its last picture, which is figureTime.

FrameSink is the parameter that makes the encoder replaceable: it owns the canvas each frame is painted onto, takes each painted frame, and hands back whatever it collected. A sink that counts the frames it is given is how the walk is checked without a device.

Encoding needs a WebCodecs VideoEncoder. A browser has one and Node does not, so the bytes are a gate with a browser in it: npm run gate:record records every committed figure in Chromium, reads each file back, and says how many pictures the file holds.

Restrictions

A mark may request only what every painter that may draw the figure implements: no filters and no blend modes. Which painters those are is Figure.painters, and left out it is all three. A figure using an SVG filter would render correctly on a page and lose the effect silently in a recording.

A clip is a rectangle and no other shape, and that exclusion is not the rule above. Both painters clip, with clip-path and with clip(). An arbitrary path clip needs a winding number counted, which is a stencil on a card, where a box is the scissor test every device already has.

A fill carries one colour and a gradient beside it. SVG names a gradient with an element carrying a document-unique identifier and a canvas with an object built from the context, and the one colour stays because a contrast reading and anything else needing a single colour has to have one.

A stroke's width is one number or a taper between two numbers along a named curve, drawn as the filled outline of its own path, since neither painter strokes at two widths. A width per point is not something a figure can name. A colour is four channels and, where its author gave it one, the name a page themes it under, so the SVG painter writes var(--name, #rrggbb) and every other painter reads the numbers: a renderer in another language cannot resolve a custom property and a shader takes numbers. colourFrom builds one from '#1b1b1b' or 'rgb(27, 27, 27)' and throws on every other form rather than guessing. Nothing reads the page, and getComputedStyle appears nowhere in the tree.

No screenshot gates this package. Every assertion reads a mark list or a number, so the suite of 1,310 tests over 87 files runs in Node without a browser. Comparisons are by tolerance rather than by hash, because Math.sin, Math.cos and Math.pow are not specified to the last bit and differ between engines. The one claim a browser is needed for is that a recording plays, and that is npm run gate:record rather than part of the suite.

Moving from 1.6.0

1.6.0 was the version before this one on npm, and 2.0.0 is the figure format: a figure is a JSON document a program reads, readFigure and writeFigure are the two calls that carry it either way, and docs/SPECIFICATION.md states the whole of it for a renderer written in another language.

The door is additive. It went from 266 names to 375 and no name was removed or renamed.

A colour changes shape, and it is the change that reaches every caller. A Colour was any CSS colour written as text and is now four channels with an optional name, so every Fill, Stroke and Stop a caller builds moves with it.

// at 1.6.0
shape('disc', circle(vec2(0, 0), 1), { fill: { colour: 'var(--accent, #fb923c)' } });
// at 2.0.0
shape('disc', circle(vec2(0, 0), 1), { fill: { colour: colourFrom('#fb923c', 'accent') } });

colourFrom reads a hex or an rgb() and takes the name a page themes the colour under, colourOf reads the channels alone, and hexOf and colourText write one back out. A form neither reads is refused rather than painted as nothing: a named colour or an hsl() read as black is a wrong picture with nothing to say it went wrong. The SVG painter still writes var(--name, #rrggbb), from the channels the colour holds, so a page themes a figure exactly as it did.

Three readers take the drawn path rather than the function behind it.

| call | at 1.6.0 | at 2.0.0 | | --- | --- | --- | | slopeOf | (of, x, step?) | (coords, curve, x) | | areaUnder | (coords, of, over, options?) | (coords, curve, options?) | | tangentAt | (coords, of, x, options?) | (coords, curve, x, options?) |

What that buys is a tangent that touches the curve a reader can see. A slope read by a central difference on the function differs from the slope of the cubic the curve is drawn as, and the two parted by 1.06e-11 at a parabola's stationary point. Reading the drawn path makes the tangent exact there. AreaOptions no longer extends PlotOptions, since the sampling belongs to the plot that made the curve, and TangentOptions loses step, since nothing is differenced any more.

plot, riemannBars, vectorField, surface3, sectionOf and streamlineOf still take a function and always will. A function making fixed geometry never had to serialise, and a figure that stores geometry stores what the function produced.

Nothing else a caller reads or implements changed shape. CanvasLike, PaintNode, Mark, Span and Stroke are what 1.6.0 published.

Further reading

docs/GUIDE.md teaches the package in order. docs/REFERENCE.md carries one entry for each of the 375 names at the door. DESIGN.md states why the design is what it is and what it will not become.

index.ts is the entire public surface, and nothing outside the package reaches a file inside it by path. A line divides the package: values and timing below it, figures and painters above, and nothing below the line imports anything above it. A test holds each of those.

MIT.