Skip to content

Commit 7a95d13

Browse files
committed
feat: new math parser for graphs
1 parent f7f44ca commit 7a95d13

7 files changed

Lines changed: 90 additions & 30 deletions

File tree

components/mdx/graph.tsx

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import { useState, Children, type ReactNode } from "react";
4+
import { safeEval } from "@/lib/math/safe-eval";
45

56
// eslint-disable-next-line @typescript-eslint/no-explicit-any
67
type AnyElement = { props: Record<string, any> };
@@ -22,8 +23,9 @@ const PALETTE = [
2223
═══════════════════════════════════════════════════ */
2324

2425
interface PlotProps {
25-
/** JS function: (x, vars) => y. `vars` contains current slider values. */
26-
fn: (x: number, vars: Record<string, number>) => number;
26+
/** Math expression string. Variable `x` is the horizontal axis.
27+
* Slider variables are available by name (e.g. `"a - b * x"`). */
28+
expr: string;
2729
/** Line color. Auto-assigned from palette if omitted. */
2830
color?: string;
2931
/** Legend label. */
@@ -140,7 +142,7 @@ export function Graph({
140142
if (!child || typeof child !== "object" || !("props" in child)) return;
141143
const p = (child as AnyElement).props;
142144

143-
if (typeof p.fn === "function") {
145+
if (typeof p.expr === "string" && !p.name) {
144146
plots.push(p as PlotProps);
145147
} else if (typeof p.name === "string" && p.min !== undefined) {
146148
sliderDefs.push(p as SliderProps);
@@ -169,14 +171,11 @@ export function Graph({
169171
const dx = (xMax - xMin) / 200;
170172
for (const plot of plots) {
171173
for (let i = 0; i <= 200; i++) {
172-
try {
173-
const y = plot.fn(xMin + i * dx, vars);
174-
if (isFinite(y) && Math.abs(y) < 1e8) {
175-
lo = Math.min(lo, y);
176-
hi = Math.max(hi, y);
177-
}
178-
} catch {
179-
/* skip evaluation errors */
174+
const x = xMin + i * dx;
175+
const y = safeEval(plot.expr, { ...vars, x });
176+
if (isFinite(y) && Math.abs(y) < 1e8) {
177+
lo = Math.min(lo, y);
178+
hi = Math.max(hi, y);
180179
}
181180
}
182181
}
@@ -221,13 +220,7 @@ export function Graph({
221220

222221
for (let i = 0; i <= SAMPLES; i++) {
223222
const x = xMin + i * dx;
224-
let y: number;
225-
try {
226-
y = plot.fn(x, vars);
227-
} catch {
228-
inPath = false;
229-
continue;
230-
}
223+
const y = safeEval(plot.expr, { ...vars, x });
231224

232225
if (!isFinite(y)) {
233226
inPath = false;

components/mdx/interactive.tsx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import { useState, Children, type ReactNode } from "react";
4+
import { safeEval } from "@/lib/math/safe-eval";
45

56
// eslint-disable-next-line @typescript-eslint/no-explicit-any
67
type AnyElement = { props: Record<string, any> };
@@ -12,8 +13,14 @@ type AnyElement = { props: Record<string, any> };
1213
interface ValueProps {
1314
/** Display label for this computed value. */
1415
label: string;
15-
/** Compute function: receives all slider/toggle vars, returns display string. */
16-
fn: (vars: Record<string, number>) => string;
16+
/** Math expression string evaluated with all slider/toggle vars. */
17+
expr: string;
18+
/** Number of decimal places in output (default: 2). */
19+
decimals?: number;
20+
/** Prefix before the number (e.g. "$", "€"). */
21+
prefix?: string;
22+
/** Suffix after the number (e.g. "%", " CZK"). */
23+
suffix?: string;
1724
}
1825

1926
/** Displays a computed value inside `<Interactive>`. */
@@ -65,8 +72,8 @@ export function Interactive({ title, children }: InteractiveProps) {
6572
if (!child || typeof child !== "object" || !("props" in child)) return;
6673
const p = (child as AnyElement).props;
6774

68-
if (typeof p.fn === "function" && typeof p.label === "string") {
69-
// Value: has fn + label, no name
75+
if (typeof p.expr === "string" && typeof p.label === "string") {
76+
// Value: has expr + label, no name
7077
valueDefs.push(p as ValueProps);
7178
} else if (
7279
typeof p.name === "string" &&
@@ -91,11 +98,11 @@ export function Interactive({ title, children }: InteractiveProps) {
9198

9299
/* ── Compute values ── */
93100
const computedValues = valueDefs.map((vd) => {
94-
try {
95-
return { label: vd.label, value: vd.fn(vars) };
96-
} catch {
97-
return { label: vd.label, value: "—" };
98-
}
101+
const raw = safeEval(vd.expr, vars);
102+
if (!isFinite(raw)) return { label: vd.label, value: "—" };
103+
const decimals = vd.decimals ?? 2;
104+
const formatted = `${vd.prefix ?? ""}${raw.toFixed(decimals)}${vd.suffix ?? ""}`;
105+
return { label: vd.label, value: formatted };
99106
});
100107

101108
return (

lib/math/safe-eval.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Parser } from "expr-eval";
2+
3+
/**
4+
* Safe math expression evaluator.
5+
*
6+
* Uses expr-eval — a proper tokenizer/parser that ONLY understands math.
7+
* No eval(), no Function(), no access to JS runtime objects.
8+
*
9+
* Supported:
10+
* Arithmetic: + - * / ^ %
11+
* Comparison: < > <= >= == !=
12+
* Logical: and or not
13+
* Ternary: condition ? a : b
14+
* Functions: sin cos tan asin acos atan sqrt abs ceil floor round
15+
* log log2 log10 exp pow min max sign trunc
16+
* Constants: PI E
17+
* Variables: any name (x, a, b, price, quantity, ...)
18+
*/
19+
20+
const parser = new Parser();
21+
22+
/** Cache parsed expressions to avoid re-parsing on every slider tick. */
23+
const cache = new Map<string, ReturnType<typeof parser.parse>>();
24+
25+
function getParsed(expr: string) {
26+
let parsed = cache.get(expr);
27+
if (!parsed) {
28+
parsed = parser.parse(expr);
29+
cache.set(expr, parsed);
30+
}
31+
return parsed;
32+
}
33+
34+
/**
35+
* Evaluate a math expression with the given variable bindings.
36+
* Returns NaN on any error (division by zero, undefined variable, etc.).
37+
*/
38+
export function safeEval(
39+
expr: string,
40+
vars: Record<string, number>
41+
): number {
42+
try {
43+
const result = getParsed(expr).evaluate(vars);
44+
return typeof result === "number" ? result : NaN;
45+
} catch {
46+
return NaN;
47+
}
48+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"dependencies": {
1717
"@mdx-js/mdx": "^3.1.1",
1818
"@wikipefia/mdx-compiler": "workspace:*",
19+
"expr-eval": "^2.0.2",
1920
"flexsearch": "^0.8.212",
2021
"gray-matter": "^4.0.3",
2122
"katex": "^0.16.28",

packages/mdx-compiler/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@wikipefia/mdx-compiler",
3-
"version": "1.0.3",
3+
"version": "1.0.4",
44
"description": "Shared MDX compiler, schemas, and validation for Wikipefia content repositories",
55
"type": "module",
66
"main": "./dist/index.js",

packages/mdx-compiler/src/components/registry.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ export const componentRegistry: Record<string, ComponentContract> = {
127127
},
128128
Plot: {
129129
props: {
130-
fn: {}, // JS function expression — cannot be statically validated
130+
expr: { required: true, type: "string" },
131131
color: { type: "string" },
132132
label: { type: "string" },
133133
dashed: { type: "boolean" },
@@ -157,7 +157,10 @@ export const componentRegistry: Record<string, ComponentContract> = {
157157
Value: {
158158
props: {
159159
label: { required: true, type: "string" },
160-
fn: {}, // JS function expression — cannot be statically validated
160+
expr: { required: true, type: "string" },
161+
decimals: { type: "number" },
162+
prefix: { type: "string" },
163+
suffix: { type: "string" },
161164
},
162165
parent: "Interactive",
163166
},

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)