Files
mathew/frontend/app/utils/plot-expression.ts
Aran Roig 6df7950161
Some checks failed
Build and Deploy Nuxt / build (push) Failing after 41s
Ai slop
2026-10-03 14:13:08 +02:00

749 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* The graphing calculator's little algebra: a hand-rolled tokenizer and
recursive-descent parser that reads an expression in x — "sin(x) + x / 4",
"2^x", "x(x+1)" — and compiles it to a function the canvas evaluates pixel
by pixel. A whole equation answers too: "y^2 + x^2 = 1" reads as a
relation F(x, y) = left − right, and the curve is where F is zero. So a
text with an "=" is one relation of x and y; without it, an expression
mentioning y is the relation that expression equals zero, and one in x
alone is the function y = f(x). No library: the grammar is small enough
for one file, and the parser can be strict about what it promises —
everything it accepts is a real function of x or a real relation in the
plane, and everything else is refused with a reason.
A name that is not x, y, one of the constants, or a called function is a
free variable — "k", "a", "radius" — and asks for a slider: the compiled
answer names the variables it speaks, and evaluation reads their values
from the scope it is handed. So an expression with a variable means the
family of curves those values trace. "k" alone, or "k = 2" ahead of a
plain number, is the declaration itself: it claims the slider and plots
nothing of its own.
Precedence is the usual one: + - bind loosest; then * / % (left, implicit
multiplication included — "2x" is 2·x); then unary minus, so -x^2 negates
the square; then ^, right-associative, its exponent may carry a sign so
2^-x reads. Functions call, constants stand, parentheses group. */
export type PlotFunction = (x: number) => number
/* a relation of the plane: the curve is its zero set */
export type PlaneFunction = (x: number, y: number) => number
/* a surface of space: the height z above the point (x, y) */
export type SurfaceFunction = (x: number, y: number) => number
/* the values a free name answers with — one slider per variable. A name the
scope does not carry yet reads as 1, where a fresh slider starts */
export type VarScope = Record<string, number>
/* why a text was refused: nothing to read, a name the calculator does not
speak, or a shape the grammar rejects */
export type PlotErrorReason = 'empty' | 'unknown' | 'syntax'
export type CompiledPlot =
// each kind carries the free variables it speaks, in order of appearance
| { ok: true; kind: 'function'; vars: string[]; fn: PlotFunction }
| { ok: true; kind: 'relation'; vars: string[]; fn: PlaneFunction }
// "(x, y)" or "P = (x, y)": a single dot at fixed coordinates, optionally
// named. Coordinates may ride the sliders, so the dot moves with them
| { ok: true; kind: 'point'; vars: string[]; x: number; y: number; label: string | null }
// "k" or "k = 2": claims a slider. A null value says the text asked for
// one without saying where it starts. Plots nothing of its own
| { ok: true; kind: 'variable'; vars: string[]; name: string; value: number | null }
| { ok: false; reason: PlotErrorReason }
type Op = '+' | '-' | '*' | '/' | '%' | '^' | '=' | '(' | ')' | ','
/* a number carries the text it was read from: the typesetter prints that,
so a half-typed decimal keeps the digits exactly as they stand */
type Token = { t: Op } | { t: 'num'; value: number; raw: string } | { t: 'id'; name: string }
type Node =
| { kind: 'num'; value: number; raw?: string }
// x and y are the plane's coordinates; every other name is a slider
| { kind: 'var'; name: string }
| { kind: 'const'; value: number }
| { kind: 'neg'; arg: Node }
| { kind: 'bin'; op: '+' | '-' | '*' | '/' | '%' | '^'; a: Node; b: Node }
| { kind: 'call'; name: string; args: Node[] }
/* the constants a name may stand for */
const CONSTANTS: Record<string, number> = {
pi: Math.PI,
tau: Math.PI * 2,
e: Math.E,
phi: (1 + Math.sqrt(5)) / 2,
}
/* the functions a name may call — all on the variable x, ln and log split
the natural and the base-10 way as calculators write them */
const FUNCTIONS: Record<string, (...args: number[]) => number> = {
sin: Math.sin,
cos: Math.cos,
tan: Math.tan,
asin: Math.asin,
acos: Math.acos,
atan: Math.atan,
sinh: Math.sinh,
cosh: Math.cosh,
tanh: Math.tanh,
ln: Math.log,
log: Math.log10,
log2: Math.log2,
log10: Math.log10,
sqrt: Math.sqrt,
cbrt: Math.cbrt,
abs: Math.abs,
exp: Math.exp,
floor: Math.floor,
ceil: Math.ceil,
round: Math.round,
sign: Math.sign,
min: (...a) => Math.min(...a),
max: (...a) => Math.max(...a),
mod: (a, b) => a % b,
}
/* min and max take as many arguments as they are given; mod takes two;
everything else wants exactly one */
function arityOk(name: string, args: number): boolean {
if (name === 'min' || name === 'max') return args >= 1
if (name === 'mod') return args === 2
return args === 1
}
class PlotParseError extends Error {
reason: Exclude<PlotErrorReason, 'empty'>
constructor(reason: 'unknown' | 'syntax') {
super(reason)
this.reason = reason
}
}
/* the input may wear its "f(x) =" beside the field; a reader who types it
too is answered the same — the rest is what gets plotted. A leading "y ="
is read by the equation rule instead, so a right side that also carries y
still means the same relation, not a mystery name. */
function stripAssignment(src: string): string {
return src.replace(/^\s*f\s*\(\s*x\s*\)\s*=/i, '')
}
function tokenize(src: string): Token[] {
/* the typographic shapes a reader may paste in stand for the plain ones */
const s = src
.replace(/[×·∗]/g, '*')
.replace(/÷/g, '/')
.replace(/[−–]/g, '-')
.replace(/π/g, 'pi')
.replace(/τ/g, 'tau')
const toks: Token[] = []
let i = 0
while (i < s.length) {
const c = s[i]!
if (c === ' ' || c === '\t' || c === '\n') {
i++
continue
}
if ((c >= '0' && c <= '9') || c === '.') {
const start = i
let dot = false
while (i < s.length) {
const d = s[i]!
if (d >= '0' && d <= '9') i++
else if (d === '.' && !dot) {
dot = true
i++
} else break
}
const text = s.slice(start, i)
const value = Number.parseFloat(text)
if (!Number.isFinite(value)) throw new PlotParseError('syntax')
toks.push({ t: 'num', value, raw: text })
continue
}
if (/[a-z_]/i.test(c)) {
const start = i
while (i < s.length && /[a-z0-9_]/i.test(s[i]!)) i++
toks.push({ t: 'id', name: s.slice(start, i).toLowerCase() })
continue
}
if (c === '*' && s[i + 1] === '*') {
i += 2
toks.push({ t: '^' }) // ** as an exponent, the programming shape
continue
}
if (c === '+' || c === '-' || c === '*' || c === '/' || c === '%' || c === '^') {
toks.push({ t: c as Op })
i++
continue
}
if (c === '=') {
toks.push({ t: '=' })
i++
continue
}
if (c === '(' || c === ')' || c === ',') {
toks.push({ t: c as Op })
i++
continue
}
throw new PlotParseError('syntax')
}
return toks
}
interface Parser {
toks: Token[]
pos: number
/* when set, a name that is not x, y, a constant or a called function
becomes a variable recorded here; when absent, it is refused */
free: Set<string> | null
}
const peek = (p: Parser): Token | null => p.toks[p.pos] ?? null
const startsAtom = (tk: Token): boolean => tk.t === 'num' || tk.t === 'id' || tk.t === '('
function parseExpr(p: Parser): Node {
let node = parseTerm(p)
for (;;) {
const tk = peek(p)
if (!tk || (tk.t !== '+' && tk.t !== '-')) break
p.pos++
node = { kind: 'bin', op: tk.t, a: node, b: parseTerm(p) }
}
return node
}
function parseTerm(p: Parser): Node {
let node = parseUnary(p)
for (;;) {
const tk = peek(p)
if (!tk) break
if (tk.t === '*' || tk.t === '/' || tk.t === '%') {
p.pos++
node = { kind: 'bin', op: tk.t, a: node, b: parseUnary(p) }
continue
}
/* nothing between two factors and a factor following: that's a
multiplication — 2x, 3(x+1), x sin(x) */
if (startsAtom(tk)) {
node = { kind: 'bin', op: '*', a: node, b: parseUnary(p) }
continue
}
break
}
return node
}
function parseUnary(p: Parser): Node {
const tk = peek(p)
if (tk && tk.t === '+') {
p.pos++
return parseUnary(p)
}
if (tk && tk.t === '-') {
p.pos++
return { kind: 'neg', arg: parseUnary(p) }
}
return parsePower(p)
}
function parsePower(p: Parser): Node {
const base = parseAtom(p)
const tk = peek(p)
if (tk && tk.t === '^') {
p.pos++
// right-associative, and the exponent may carry its own sign
return { kind: 'bin', op: '^', a: base, b: parseUnary(p) }
}
return base
}
function parseAtom(p: Parser): Node {
const tk = peek(p)
if (!tk) throw new PlotParseError('syntax')
if (tk.t === 'num') {
p.pos++
return { kind: 'num', value: tk.value, raw: tk.raw }
}
if (tk.t === '(') {
p.pos++
const inner = parseExpr(p)
const close = peek(p)
if (!close || close.t !== ')') throw new PlotParseError('syntax')
p.pos++
return inner
}
if (tk.t === 'id') {
p.pos++
if (tk.name === 'x') return { kind: 'var', name: 'x' }
if (tk.name === 'y') return { kind: 'var', name: 'y' }
if (Object.hasOwn(CONSTANTS, tk.name)) return { kind: 'const', value: CONSTANTS[tk.name]! }
if (Object.hasOwn(FUNCTIONS, tk.name)) {
const open = peek(p)
if (!open || open.t !== '(') throw new PlotParseError('syntax')
p.pos++
const args: Node[] = [parseExpr(p)]
while (peek(p)?.t === ',') {
p.pos++
args.push(parseExpr(p))
}
const close = peek(p)
if (!close || close.t !== ')') throw new PlotParseError('syntax')
p.pos++
if (!arityOk(tk.name, args.length)) throw new PlotParseError('syntax')
return { kind: 'call', name: tk.name, args }
}
// no coordinate, no constant, no name the calculator calls — then it is
// a variable: a slider the reader sets, shared wherever its name is spoken
if (p.free) {
p.free.add(tk.name)
return { kind: 'var', name: tk.name }
}
throw new PlotParseError('unknown')
}
throw new PlotParseError('syntax')
}
function evaluate(node: Node, x: number, y: number, scope: VarScope): number {
switch (node.kind) {
case 'num':
return node.value
case 'var': {
if (node.name === 'x') return x
if (node.name === 'y') return y
// a slider's set value; until it carries one, where every slider starts
const v = scope[node.name]
return typeof v === 'number' && Number.isFinite(v) ? v : 1
}
case 'const':
return node.value
case 'neg':
return -evaluate(node.arg, x, y, scope)
case 'call': {
const fn = FUNCTIONS[node.name]!
return fn(...node.args.map((a) => evaluate(a, x, y, scope)))
}
case 'bin': {
const a = evaluate(node.a, x, y, scope)
const b = evaluate(node.b, x, y, scope)
switch (node.op) {
case '+':
return a + b
case '-':
return a - b
case '*':
return a * b
case '/':
return a / b
case '%':
return a % b
case '^':
return Math.pow(a, b)
}
}
}
}
/* whether y stands anywhere in a tree — the difference between a function of
x, which the canvas can trace column by column, and a relation, which it
must search cell by cell */
function mentionsY(node: Node): boolean {
switch (node.kind) {
case 'var':
return node.name === 'y'
case 'neg':
return mentionsY(node.arg)
case 'bin':
return mentionsY(node.a) || mentionsY(node.b)
case 'call':
return node.args.some(mentionsY)
default:
return false
}
}
/* whether x or y stands anywhere in a tree. A point's coordinate may ride
the sliders — each has one value to place the dot at — but a plane
variable has no single value, so the dot would have nowhere to go */
function mentionsPlane(node: Node): boolean {
switch (node.kind) {
case 'var':
return node.name === 'x' || node.name === 'y'
case 'neg':
return mentionsPlane(node.arg)
case 'bin':
return mentionsPlane(node.a) || mentionsPlane(node.b)
case 'call':
return node.args.some(mentionsPlane)
default:
return false
}
}
/* a declaration's value is a plain number — numbers, pi, sqrt(2)… welcome,
sliders and x and y not */
function isConstant(node: Node): boolean {
switch (node.kind) {
case 'var':
return false
case 'neg':
return isConstant(node.arg)
case 'bin':
return isConstant(node.a) && isConstant(node.b)
case 'call':
return node.args.every(isConstant)
default:
return true
}
}
/* the inside of a "(…, …)" split into its two coordinate token runs, or null
unless there is exactly one comma at this depth */
function splitPair(inner: Token[]): [Token[], Token[]] | null {
let depth = 0
let comma = -1
for (let i = 0; i < inner.length; i++) {
const t = inner[i]!
if (t.t === '(') depth++
else if (t.t === ')') depth--
else if (t.t === ',' && depth === 0) {
if (comma !== -1) return null // more than one separator: not a plane point
comma = i
}
}
if (comma === -1) return null
return [inner.slice(0, comma), inner.slice(comma + 1)]
}
/* tokens shaped "(x, y)": one pair of parens wrapping the whole run, split by
a single top-level comma — the point "(2, 3)". A "(x+1)" has no comma and a
"(1)+(2)" closes its parens early, so neither is read as a point. */
function pointPair(toks: Token[]): [Token[], Token[]] | null {
if (toks.length < 4 || toks[0]!.t !== '(' || toks[toks.length - 1]!.t !== ')') return null
let depth = 0
for (let i = 0; i < toks.length; i++) {
if (toks[i]!.t === '(') depth++
else if (toks[i]!.t === ')') depth--
if (depth === 0 && i < toks.length - 1) return null // parens closed before the end
}
return splitPair(toks.slice(1, toks.length - 1))
}
/* one run of tokens, the whole of it — a leftover is a shape the grammar
does not know. Free variables are collected into `free` when given */
function parseAll(toks: Token[], free: Set<string> | null = null): Node {
const p: Parser = { toks, pos: 0, free }
const ast = parseExpr(p)
if (p.pos !== toks.length) throw new PlotParseError('syntax')
return ast
}
/* a name the reader may claim for a slider: not one of the plane's two
coordinates, not one of the constants, not a function the calculator calls */
function declName(name: string): string | null {
if (name === 'x' || name === 'y') return null
if (Object.hasOwn(CONSTANTS, name) || Object.hasOwn(FUNCTIONS, name)) return null
return name
}
/* read a slider's claim from a token run — "k" alone, or "k = 2" with a
claimable name ahead of a plain number — or null when the text claims
none. The value reads strictly: a "P = (2, 3)" or "k = x" is a point and
a relation, not declarations, and says so by not parsing as a number. */
function declarationOf(toks: Token[]): { name: string; value: number | null } | null {
if (toks.length === 0 || toks[0]!.t !== 'id') return null
const name = declName(toks[0]!.name)
if (!name) return null
if (toks.length === 1) return { name, value: null }
if (toks.length > 2 && toks[1]!.t === '=' && !toks.slice(2).some((tk) => tk.t === '=')) {
try {
const rhs = parseAll(toks.slice(2)) // strict: a declaration's value is a plain number
if (isConstant(rhs)) return { name, value: evaluate(rhs, 0, 0, {}) }
} catch {
/* not a plain number — not a declaration, whatever it reads as instead */
}
}
return null
}
/* read one expression, equation, point or slider declaration; a refusal says
whether a name was not known or the shape was wrong — an empty text is the
caller's to treat as "nothing asked for", not as an error. A "(x, y)" — or
"P = (x, y)" with a name out front — is a point at fixed coordinates. With
an "=" otherwise the text is the relation left − right = 0 (a lone "y ="
ahead of an expression in x is still the fast function); without it, an
expression in x is y = f(x) and one carrying y is the relation that
expression equals zero. A name none of those readings speaks — "k" alone,
or "k = 2" ahead of a plain number — declares a slider: it plots nothing
and its value answers everywhere the name is spoken. Functions, relations
and points list the variables they speak in `vars` and read their values
from `scope` at their every evaluation — so one dragged slider moves the
whole family of curves at once. */
export function compilePlotExpression(src: string, scope: VarScope = {}): CompiledPlot {
if (src.length > 400) return { ok: false, reason: 'syntax' }
try {
const toks = tokenize(stripAssignment(src))
if (toks.length === 0) return { ok: false, reason: 'empty' }
const at = toks.findIndex((tk) => tk.t === '=')
const free = new Set<string>()
// A slider claiming its own row: "k", or "k = 2" ahead of a plain number
const decl = declarationOf(toks)
if (decl) return { ok: true, kind: 'variable', vars: [decl.name], name: decl.name, value: decl.value }
// A point: "(x, y)" on its own, or "P = (x, y)" with a lone name ahead of
// the "=". Coordinates may ride the sliders — and must be finite once read.
const named = at === 1 && toks[0]!.t === 'id'
const pair = pointPair(named ? toks.slice(2) : toks)
if (pair) {
const cx = parseAll(pair[0], free)
const cy = parseAll(pair[1], free)
if (mentionsPlane(cx) || mentionsPlane(cy)) return { ok: false, reason: 'syntax' }
const x = evaluate(cx, 0, 0, scope)
const y = evaluate(cy, 0, 0, scope)
if (!Number.isFinite(x) || !Number.isFinite(y)) return { ok: false, reason: 'syntax' }
const label = named ? src.slice(0, src.indexOf('=')).trim() : null
return { ok: true, kind: 'point', vars: [...free], x, y, label: label || null }
}
if (at === -1) {
const ast = parseAll(toks, free)
return mentionsY(ast)
? { ok: true, kind: 'relation', vars: [...free], fn: (x, y) => evaluate(ast, x, y, scope) }
: { ok: true, kind: 'function', vars: [...free], fn: (x) => evaluate(ast, x, 0, scope) }
}
const left = toks.slice(0, at)
const right = toks.slice(at + 1)
if (left.length === 0 || right.length === 0 || right.some((tk) => tk.t === '='))
throw new PlotParseError('syntax')
const rhs = parseAll(right, free)
// "y = <only x>" is the explicit curve it always was: x alone decides it
if (left.length === 1 && left[0]!.t === 'id' && left[0]!.name === 'y' && !mentionsY(rhs))
return { ok: true, kind: 'function', vars: [...free], fn: (x) => evaluate(rhs, x, 0, scope) }
const lhs = parseAll(left, free)
return {
ok: true,
kind: 'relation',
vars: [...free],
fn: (x, y) => evaluate(lhs, x, y, scope) - evaluate(rhs, x, y, scope),
}
} catch (err) {
if (err instanceof PlotParseError) return { ok: false, reason: err.reason }
return { ok: false, reason: 'syntax' }
}
}
/* the inside of the 3D field's "z = …" — a lone "z =" ahead of an expression
in x and y; the reader may also write "f(x, y) =" or type the height with
no left side at all, which reads the same */
function stripSurfaceAssignment(src: string): string {
return src
.replace(/^\s*f\s*\(\s*x\s*,\s*y\s*\)\s*=/i, '')
.replace(/^\s*z\s*=/i, '')
}
export type CompiledSurface =
// each carries the free variables it speaks, in order of appearance
| { ok: true; kind: 'surface'; vars: string[]; fn: SurfaceFunction }
// "k" or "k = 2": claims a slider, as in the plane's fields. A null value
// says the text asked for one without saying where it starts. Draws nothing
| { ok: true; kind: 'variable'; vars: string[]; name: string; value: number | null }
| { ok: false; reason: PlotErrorReason }
/* compile the height of a surface above (x, y): the same algebra as the
plane, only read as z = f(x, y). A "z =" ahead of the expression is the
height's own name and sheds; what is left must speak x and y (constants
are welcome — a level surface is still a surface). Names x and y are not
are slider variables read from scope at each evaluation, and "k" or
"k = 2" is the plane's slider declaration — space and plane wear the very
same variables. A stray "=" is still a leftover: space answers a height
alone, and refuses whatever cannot read as one. */
export function compileSurfaceExpression(src: string, scope: VarScope = {}): CompiledSurface {
if (src.length > 400) return { ok: false, reason: 'syntax' }
try {
const body = stripSurfaceAssignment(src)
const toks = tokenize(body)
if (toks.length === 0) return { ok: false, reason: 'empty' }
// a slider claiming its own row: "k", or "k = 2" ahead of a plain number
const decl = declarationOf(toks)
if (decl) return { ok: true, kind: 'variable', vars: [decl.name], name: decl.name, value: decl.value }
const free = new Set<string>()
const ast = parseAll(toks, free)
return { ok: true, kind: 'surface', vars: [...free], fn: (x, y) => evaluate(ast, x, y, scope) }
} catch (err) {
if (err instanceof PlotParseError) return { ok: false, reason: err.reason }
return { ok: false, reason: 'syntax' }
}
}
/* ---------- printing the same maths back typeset ---------- */
/* the calculator's fields echo their text as set mathematics while it is
typed (Desmos' way). Printing the parser's own tree keeps the two
readings honest: what is displayed is exactly what would be plotted —
so a text that does not compile simply has no type-setting, and an
unfinished draft stays plain text until it reads right. Fractions are
fractions, radicals are radicals, exponents rise; parentheses appear
only where precedence would otherwise lie. */
const LATEX_FUNCTIONS: Record<string, string> = {
sin: '\\sin',
cos: '\\cos',
tan: '\\tan',
asin: '\\arcsin',
acos: '\\arccos',
atan: '\\arctan',
sinh: '\\sinh',
cosh: '\\cosh',
tanh: '\\tanh',
ln: '\\ln',
log: '\\log',
log2: '\\log_{2}',
log10: '\\log_{10}',
min: '\\min',
max: '\\max',
}
/* a folded constant answers as its symbol when its value is exactly one */
function constSymbol(v: number): string | null {
if (v === Math.PI) return '\\pi'
if (v === Math.PI * 2) return '\\tau'
if (v === Math.E) return 'e'
if (v === (1 + Math.sqrt(5)) / 2) return '\\varphi'
return null
}
/* how tightly a printed node binds: one that binds looser than the context
it stands in gains parentheses */
function nodePrec(n: Node): number {
if (n.kind === 'bin') {
if (n.op === '+' || n.op === '-') return 10
if (n.op === '^') return 40
if (n.op === '/') return 90 // a \\frac carries its own grouping
return 20 // * and %
}
if (n.kind === 'neg') return 30
return 100
}
function printNode(n: Node, ctx: number): string {
const s = printBare(n)
return nodePrec(n) < ctx ? `\\left(${s}\\right)` : s
}
/* two factors standing side by side are multiplied silently — unless the
right one is a number (numbers must not run together), or a bare number
is followed by a fraction (which would read as a mixed number) */
function needsDot(a: Node, b: Node): boolean {
if (b.kind === 'num') return true
return a.kind === 'num' && b.kind === 'bin' && b.op === '/'
}
function printBare(n: Node): string {
switch (n.kind) {
case 'num':
return n.raw ?? String(n.value)
case 'var':
return n.name
case 'const':
return constSymbol(n.value) ?? String(n.value)
case 'neg':
return `-${printNode(n.arg, 30)}`
case 'call':
return printCall(n.name, n.args)
case 'bin': {
switch (n.op) {
case '+':
return `${printNode(n.a, 10)} + ${printNode(n.b, 11)}`
case '-':
return `${printNode(n.a, 10)} - ${printNode(n.b, 11)}`
case '*': {
const a = printNode(n.a, 20)
// a looser factor beside a silent product would change its sense —
// even a leading minus reads as subtraction there. The gap between
// factors is a space: free in math mode, and it keeps a \\pi from
// running into the next atom as one control word
const b = printNode(n.b, 31)
return needsDot(n.a, n.b) ? `${a} \\cdot ${b}` : `${a} ${b}`
}
case '/':
return `\\frac{${printNode(n.a, 0)}}{${printNode(n.b, 0)}}`
case '%':
return `${printNode(n.a, 20)} \\bmod ${printNode(n.b, 21)}`
case '^':
// braces group the exponent outright, so it never needs parentheses;
// an exp(...) base is already a superscribed e — it takes parens
// rather than carry a second superscript
return `${n.a.kind === 'call' && n.a.name === 'exp' ? `\\left(${printBare(n.a)}\\right)` : printNode(n.a, 41)}^{${printNode(n.b, 0)}}`
}
}
}
}
function printCall(name: string, args: Node[]): string {
const one = args[0] ?? { kind: 'num', value: 0 }
switch (name) {
case 'sqrt':
return `\\sqrt{${printNode(one, 0)}}`
case 'cbrt':
return `\\sqrt[3]{${printNode(one, 0)}}`
case 'abs':
return `\\left|${printNode(one, 0)}\\right|`
case 'floor':
return `\\left\\lfloor ${printNode(one, 0)} \\right\\rfloor`
case 'ceil':
return `\\left\\lceil ${printNode(one, 0)} \\right\\rceil`
case 'exp':
return `e^{${printNode(one, 0)}}`
case 'mod':
return `${printNode(args[0] ?? one, 20)} \\bmod ${printNode(args[1] ?? one, 21)}`
default: {
const label = LATEX_FUNCTIONS[name] ?? name
return `${label}\\left(${args.map((a) => printNode(a, 0)).join(',\\, ')}\\right)`
}
}
}
/* the whole text of a field as one typeset line: the same readings the
compiler runs — an "f(x) =" or "z =" ahead of the maths is shed, a lone
"y =" the field's gutter already wears is shed too (the gutter prints
it), a "(x, y)" — optionally named — is a point, a claimable name alone
or ahead of a plain number claims a slider, and any other "=" is the
equation it states. Free variables print as themselves, italic like the
rest. Null when the text does not read: the caller keeps the plain text,
and its red, until it does. */
export function expressionToLatex(src: string): string | null {
if (!src.trim() || src.length > 400) return null
try {
const toks = tokenize(stripSurfaceAssignment(stripAssignment(src)))
if (toks.length === 0) return null
const at = toks.findIndex((tk) => tk.t === '=')
const free = new Set<string>()
const named = at === 1 && toks[0]!.t === 'id'
const pair = pointPair(named ? toks.slice(2) : toks)
if (pair) {
const coords = `\\left(${printNode(parseAll(pair[0], free), 0)},\\, ${printNode(parseAll(pair[1], free), 0)}\\right)`
if (!named) return coords
const label = src.slice(0, src.indexOf('=')).trim()
const name = /^[a-z][a-z0-9_]*$/i.test(label) ? label : `\\text{${label.replace(/[\\{}]/g, '')}}`
return `${name} = ${coords}`
}
if (at === -1) return printNode(parseAll(toks, free), 0)
const left = toks.slice(0, at)
const right = toks.slice(at + 1)
if (left.length === 0 || right.length === 0 || right.some((tk) => tk.t === '=')) return null
// "y = <only x>" is the function the field's gutter already wears: the
// type-set line answers with its right side alone
if (left.length === 1 && left[0]!.t === 'id' && left[0]!.name === 'y' && !right.some((tk) => tk.t === 'id' && tk.name === 'y'))
return printNode(parseAll(right, free), 0)
// "=" binds loosest of all — neither side ever needs parentheses
return `${printNode(parseAll(left, free), 0)} = ${printNode(parseAll(right, free), 0)}`
} catch {
return null
}
}