svg
bun add @stopcock/svg@stopcock/svg builds SVG as typed values. Every non-constructor operation that combines data with configuration supports both data-first calls and curried data-last calls under the same name. User code works with shapes, paints, paths, filters, and transforms; the renderer owns XML, ids, escaping, and <defs> hoisting.
See it compose with image and color processing in the SVG + Color Batch showcase.
import { pipe } from '@stopcock/fp'import { rgb } from '@stopcock/color'import { circle, fill, render, rotate, stroke, translate, viewBox } from '@stopcock/svg'
const base = circle(32)const moved = translate(base, 50, 50)const turned = rotate(moved, 15)const painted = fill(turned, rgb(1, 0, 0))const outlined = stroke(painted, rgb(0, 0, 0), 2)const dataFirstBadge = viewBox(outlined, 0, 0, 100, 100)
const dataLastBadge = pipe( circle(32), translate(50, 50), rotate(15), fill(rgb(1, 0, 0)), stroke(rgb(0, 0, 0), 2), viewBox(0, 0, 100, 100),)
render(dataFirstBadge)pipe(dataLastBadge, render())Core model
Section titled “Core model”type Mat = readonly [number, number, number, number, number, number]
circle(r)rect(w, h)ellipse(rx, ry)line(x1, y1, x2, y2)text(value, size?)group(children)use(target)viewBox(node, x, y, w, h) / viewBox(x, y, w, h)(node)render(node, options?) / render(options?)(node)Constructors stay direct. Data-taking operators expose a direct overload and return Node => Node when called with configuration only, so the same name works in pipe:
fill(node, paint) / fill(paint)(node)stroke(node, paint, width, opts?) / stroke(paint, width, opts?)(node)opacity(node, alpha) / opacity(alpha)(node)translate(node, dx, dy) / translate(dx, dy)(node)rotate(node, degrees, cx?, cy?) / rotate(degrees, cx?, cy?)(node)scale(node, sx, sy?) / scale(sx, sy?)(node)skewX(node, degrees) / skewX(degrees)(node)skewY(node, degrees) / skewY(degrees)(node)toClip(node) / toClip()(node)toMask(node) / toMask()(node)clip(node, clipPath) / clip(clipPath)(node)mask(node, maskValue) / mask(maskValue)(node)filter(node, filterValue) / filter(filterValue)(node)Paint and defs
Section titled “Paint and defs”Gradients, patterns, clips, masks, filters, and symbols are hoisted into <defs> by reference equality. Reuse a const to reuse a definition; allocate a new value to get a distinct definition.
import { pipe } from '@stopcock/fp'import { linear, rect, circle, fill, group, render } from '@stopcock/svg'import { rgb } from '@stopcock/color'
const brand = linear([ { offset: 0, color: rgb(1, 0, 0) }, { offset: 1, color: rgb(0, 0, 1) },])
const icon = group([fill(rect(20, 20), brand), pipe(circle(8), fill(brand))])
render(icon)Every path operation that consumes a path exposes both lanes:
path.lineTo(pathValue, x, y) / path.lineTo(x, y)(pathValue)path.curveTo(pathValue, x1, y1, x2, y2, x, y) / path.curveTo(x1, y1, x2, y2, x, y)(pathValue)path.quadTo(pathValue, x1, y1, x, y) / path.quadTo(x1, y1, x, y)(pathValue)path.arcTo(pathValue, rx, ry, large, sweep, x, y) / path.arcTo(rx, ry, large, sweep, x, y)(pathValue)path.close(pathValue) / path.close()(pathValue)path.toNode(pathValue) / path.toNode()(pathValue)import { pipe } from '@stopcock/fp'import { path, render } from '@stopcock/svg'
const firstEdge = path.lineTo(path.start(0, 0), 100, 0)const dataFirstPath = path.lineTo(firstEdge, 50, 80)const dataFirstTriangle = path.toNode(path.close(dataFirstPath))
const dataLastTriangle = pipe( path.start(0, 0), path.lineTo(100, 0), path.lineTo(50, 80), path.close(), path.toNode(),)
render(dataFirstTriangle)pipe(dataLastTriangle, render())Affine multiplication and rendering follow the same rule:
mul(a, b) / mul(b)(a)render(node, options?) / render(options?)(node)Filters
Section titled “Filters”import { pipe } from '@stopcock/fp'import { filter, rect, render } from '@stopcock/svg'
const identity = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0] as const
const composed = filter.compose([filter.blur(2), filter.colorMatrix(identity)])const dataFirstFiltered = filter(rect(100, 40), composed)
const dataLastFiltered = pipe(rect(100, 40), filter(composed))
render(dataFirstFiltered)pipe(dataLastFiltered, render())filter.colorMatrix accepts a row-major 4x5 SVG color matrix: 20 numbers for RGBA plus bias.
Linear algebra helpers
Section titled “Linear algebra helpers”Import from @stopcock/svg/la when you want helpers backed by @stopcock/la.
lerpTransform(a, b, t) / lerpTransform(b, t)(a)toQuad(node, corners) / toQuad(corners)(node)hitTest(root, point) / hitTest(point)(root)fitBezier(points, options?) / fitBezier(options?)(points)alignToPrincipalAxis(node, points) / alignToPrincipalAxis(points)(node)symmetry(node, count, step) / symmetry(count, step)(node)import { pipe } from '@stopcock/fp'import { rect } from '@stopcock/svg'import { toQuad, lerpTransform, symmetry } from '@stopcock/svg/la'
const corners = [ [0, 0], [120, 10], [100, 80], [10, 90],] as const
const dataFirstWarped = toQuad(rect(1, 1), corners)const dataLastWarped = pipe(rect(1, 1), toQuad(corners))
const identity = [1, 0, 0, 1, 0, 0] as constconst target = [1, 0, 0, 1, 100, 0] as constconst halfway = lerpTransform(identity, target, 0.5)const halfwayInPipe = pipe(identity, lerpTransform(target, 0.5))
const dataFirstRepeated = symmetry(dataFirstWarped, 8, halfway)const dataLastRepeated = pipe(dataLastWarped, symmetry(8, halfwayInPipe))