Skip to content

svg

Terminal window
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())
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)

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)
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.

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 const
const target = [1, 0, 0, 1, 100, 0] as const
const 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))