749 lines
28 KiB
TypeScript
749 lines
28 KiB
TypeScript
/* 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
|
||
}
|
||
}
|