{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "math-graph",
  "title": "Math Graph",
  "description": "A real-time visual math function builder with animated SVG curves, a pan/zoom canvas, crosshair tooltip, and support for multiple expressions simultaneously.",
  "dependencies": [
    "motion",
    "react-use-measure"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/components/unlumen/math-graph/index.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport useMeasure from \"react-use-measure\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { evaluateMathExpression } from \"./math-expression\";\n\nconst CURVE_COLORS = [\"#3b82f6\", \"#22d3ee\", \"#f472b6\", \"#a78bfa\"];\n\nconst CURVE_BG = [\n  \"rgba(59,130,246,0.08)\",\n  \"rgba(34,211,238,0.08)\",\n  \"rgba(244,114,182,0.08)\",\n  \"rgba(167,139,250,0.08)\",\n];\n\nfunction evalMath(expr: string, x: number): number | null {\n  return evaluateMathExpression(expr, x);\n}\n\nfunction validateExpr(expr: string): boolean {\n  if (!expr.trim()) return false;\n  try {\n    const r = evalMath(expr, 1);\n    return r !== null || evalMath(expr, 0) !== null;\n  } catch {\n    return false;\n  }\n}\n\nfunction buildPath(\n  expr: string,\n  xMin: number,\n  xMax: number,\n  yMin: number,\n  yMax: number,\n  width: number,\n  height: number,\n  steps = 500,\n): string {\n  if (!width || !height) return \"\";\n\n  const toSX = (x: number) => ((x - xMin) / (xMax - xMin)) * width;\n  const toSY = (y: number) => height - ((y - yMin) / (yMax - yMin)) * height;\n\n  const yRange = yMax - yMin;\n  const segments: string[] = [];\n  let seg: string[] = [];\n  let lastY: number | null = null;\n\n  for (let i = 0; i <= steps; i++) {\n    const mx = xMin + (i / steps) * (xMax - xMin);\n    const my = evalMath(expr, mx);\n\n    if (my === null) {\n      if (seg.length > 1) segments.push(seg.join(\" \"));\n      seg = [];\n      lastY = null;\n      continue;\n    }\n\n    if (lastY !== null && Math.abs(my - lastY) > yRange * 2) {\n      if (seg.length > 1) segments.push(seg.join(\" \"));\n      seg = [];\n    }\n\n    const sx = toSX(mx).toFixed(2);\n    const sy = toSY(my).toFixed(2);\n    seg.push(seg.length === 0 ? `M ${sx} ${sy}` : `L ${sx} ${sy}`);\n    lastY = my;\n  }\n\n  if (seg.length > 1) segments.push(seg.join(\" \"));\n  return segments.join(\" \");\n}\n\nfunction niceStep(range: number, targetCount: number): number {\n  const rough = range / targetCount;\n  const pow10 = Math.pow(10, Math.floor(Math.log10(Math.abs(rough) || 1)));\n  for (const n of [1, 2, 2.5, 5, 10]) {\n    if (n * pow10 >= rough) return n * pow10;\n  }\n  return pow10 * 10;\n}\n\nfunction getTicks(min: number, max: number, step: number): number[] {\n  const ticks: number[] = [];\n  const start = Math.ceil(min / step) * step;\n  for (let t = start; t <= max + 1e-9; t += step) {\n    ticks.push(parseFloat(t.toPrecision(10)));\n  }\n  return ticks;\n}\n\nfunction fmtLabel(n: number): string {\n  if (n === 0) return \"0\";\n  if (Math.abs(n) >= 1000)\n    return n.toLocaleString(\"en-US\", { maximumFractionDigits: 0 });\n  if (Number.isInteger(n)) return String(n);\n  return n.toPrecision(3).replace(/\\.?0+$/, \"\");\n}\n\ntype ExprEntry = {\n  id: string;\n  expr: string;\n  color: string;\n  bg: string;\n  valid: boolean;\n};\n\nconst DEFAULT_PRESETS = [\n  \"sin(x)\",\n  \"x**2 / 4 - 2\",\n  \"tan(x)\",\n  \"exp(-x**2 / 2)\",\n  \"abs(sin(x)) * 3\",\n  \"log(abs(x) + 1)\",\n] as const;\n\ninterface MathGraphProps {\n  initialExpressions?: string[];\n  xMin?: number;\n  xMax?: number;\n  resolution?: number;\n  showGrid?: boolean;\n  showLabels?: boolean;\n  animated?: boolean;\n  className?: string;\n}\n\nfunction MathGraph({\n  initialExpressions = [\"sin(x)\", \"cos(x)\"],\n  xMin: initXMin = -2 * Math.PI,\n  xMax: initXMax = 2 * Math.PI,\n  resolution = 600,\n  showGrid = true,\n  showLabels = true,\n  animated = true,\n  className,\n}: MathGraphProps) {\n  const [measureRef, { width, height }] = useMeasure();\n\n  const [xMin, setXMin] = React.useState(initXMin);\n  const [xMax, setXMax] = React.useState(initXMax);\n  const isDefaultView =\n    Math.abs(xMin - initXMin) < 0.01 && Math.abs(xMax - initXMax) < 0.01;\n\n  const [entries, setEntries] = React.useState<ExprEntry[]>(() =>\n    initialExpressions.slice(0, 4).map((expr, i) => ({\n      id: crypto.randomUUID(),\n      expr,\n      color: CURVE_COLORS[i % CURVE_COLORS.length]!,\n      bg: CURVE_BG[i % CURVE_BG.length]!,\n      valid: true,\n    })),\n  );\n\n  // debounced to avoid recomputing expensive paths/yRange on every keystroke\n  const [debounced, setDebounced] = React.useState<ExprEntry[]>(entries);\n  React.useEffect(() => {\n    const id = setTimeout(() => setDebounced(entries), 220);\n    return () => clearTimeout(id);\n  }, [entries]);\n\n  // increments only on zoom (not pan) — used as motion.path key to replay the draw animation\n  const [zoomKey, setZoomKey] = React.useState(0);\n  const prevScaleRef = React.useRef(initXMax - initXMin);\n  React.useEffect(() => {\n    const newScale = xMax - xMin;\n    if (\n      Math.abs(newScale - prevScaleRef.current) / (prevScaleRef.current || 1) >\n      5e-4\n    ) {\n      prevScaleRef.current = newScale;\n      setZoomKey((k) => k + 1);\n    }\n  }, [xMin, xMax]);\n\n  const [hover, setHover] = React.useState<{\n    svgX: number;\n    svgY: number;\n    mathX: number;\n  } | null>(null);\n\n  const drag = React.useRef<{\n    startSvgX: number;\n    startXMin: number;\n    startXMax: number;\n  } | null>(null);\n\n  const { yMin, yMax } = React.useMemo(() => {\n    if (!width) return { yMin: -5, yMax: 5 };\n    const ys: number[] = [];\n    for (const e of debounced) {\n      if (!e.valid || !e.expr) continue;\n      for (let i = 0; i <= 300; i++) {\n        const x = xMin + (i / 300) * (xMax - xMin);\n        const y = evalMath(e.expr, x);\n        if (y !== null) ys.push(y);\n      }\n    }\n    if (ys.length === 0) return { yMin: -5, yMax: 5 };\n    const lo = Math.min(...ys);\n    const hi = Math.max(...ys);\n    const pad = Math.max((hi - lo) * 0.18, 1.5);\n    return { yMin: lo - pad, yMax: hi + pad };\n  }, [debounced, xMin, xMax, width]);\n\n  const toSX = React.useCallback(\n    (x: number) => ((x - xMin) / (xMax - xMin)) * width,\n    [xMin, xMax, width],\n  );\n  const toSY = React.useCallback(\n    (y: number) => height - ((y - yMin) / (yMax - yMin)) * height,\n    [yMin, yMax, height],\n  );\n  const toMX = React.useCallback(\n    (sx: number) => xMin + (sx / width) * (xMax - xMin),\n    [xMin, xMax, width],\n  );\n\n  const xStep = niceStep(xMax - xMin, Math.max(4, Math.floor(width / 80)));\n  const yStep = niceStep(yMax - yMin, Math.max(3, Math.floor(height / 55)));\n  const xTicks = getTicks(xMin, xMax, xStep);\n  const yTicks = getTicks(yMin, yMax, yStep);\n  const axisY = toSY(0);\n  const axisX = toSX(0);\n\n  const paths = React.useMemo(() => {\n    if (!width || !height) return [];\n    return debounced.map((e) => ({\n      ...e,\n      d:\n        e.valid && e.expr\n          ? buildPath(e.expr, xMin, xMax, yMin, yMax, width, height, resolution)\n          : \"\",\n    }));\n  }, [debounced, xMin, xMax, yMin, yMax, width, height, resolution]);\n\n  // non-passive wheel listener so e.preventDefault() blocks page scroll\n  const graphDivRef = React.useRef<HTMLDivElement>(null);\n\n  // stable refs so the wheel handler always has fresh values without re-creating\n  const xMinRef = React.useRef(xMin);\n  const xMaxRef = React.useRef(xMax);\n  const widthRef = React.useRef(width);\n  React.useEffect(() => {\n    xMinRef.current = xMin;\n  }, [xMin]);\n  React.useEffect(() => {\n    xMaxRef.current = xMax;\n  }, [xMax]);\n  React.useEffect(() => {\n    widthRef.current = width;\n  }, [width]);\n\n  React.useEffect(() => {\n    const el = graphDivRef.current;\n    if (!el) return;\n    const handler = (e: WheelEvent) => {\n      e.preventDefault();\n      const rect = el.getBoundingClientRect();\n      const sx = e.clientX - rect.left;\n      const curXMin = xMinRef.current;\n      const curXMax = xMaxRef.current;\n      const curWidth = widthRef.current;\n      const mx = curXMin + (sx / curWidth) * (curXMax - curXMin);\n      const factor = e.deltaY > 0 ? 1.14 : 0.88;\n      setXMin(mx + (curXMin - mx) * factor);\n      setXMax(mx + (curXMax - mx) * factor);\n    };\n    el.addEventListener(\"wheel\", handler, { passive: false });\n    return () => el.removeEventListener(\"wheel\", handler);\n  }, []);\n\n  const handleMouseDown = React.useCallback(\n    (e: React.MouseEvent<HTMLDivElement>) => {\n      const rect = e.currentTarget.getBoundingClientRect();\n      drag.current = {\n        startSvgX: e.clientX - rect.left,\n        startXMin: xMin,\n        startXMax: xMax,\n      };\n    },\n    [xMin, xMax],\n  );\n\n  const handleMouseMove = React.useCallback(\n    (e: React.MouseEvent<HTMLDivElement>) => {\n      const rect = e.currentTarget.getBoundingClientRect();\n      const sx = e.clientX - rect.left;\n      const sy = e.clientY - rect.top;\n      const mx = toMX(sx);\n      setHover({ svgX: sx, svgY: sy, mathX: mx });\n\n      if (drag.current) {\n        const dx = sx - drag.current.startSvgX;\n        const range = drag.current.startXMax - drag.current.startXMin;\n        const shift = -(dx / width) * range;\n        setXMin(drag.current.startXMin + shift);\n        setXMax(drag.current.startXMax + shift);\n      }\n    },\n    [toMX, width],\n  );\n\n  const handleMouseUp = React.useCallback(() => {\n    drag.current = null;\n  }, []);\n\n  const handleMouseLeave = React.useCallback(() => {\n    drag.current = null;\n    setHover(null);\n  }, []);\n\n  const updateEntry = React.useCallback((id: string, expr: string) => {\n    setEntries((prev) =>\n      prev.map((e) => {\n        if (e.id !== id) return e;\n        return { ...e, expr, valid: validateExpr(expr) };\n      }),\n    );\n  }, []);\n\n  const addEntry = React.useCallback(() => {\n    if (entries.length >= 4) return;\n    const idx = entries.length;\n    setEntries((prev) => [\n      ...prev,\n      {\n        id: crypto.randomUUID(),\n        expr: \"\",\n        color: CURVE_COLORS[idx % CURVE_COLORS.length]!,\n        bg: CURVE_BG[idx % CURVE_BG.length]!,\n        valid: false,\n      },\n    ]);\n  }, [entries.length]);\n\n  const removeEntry = React.useCallback((id: string) => {\n    setEntries((prev) => prev.filter((e) => e.id !== id));\n  }, []);\n\n  const applyPreset = React.useCallback((expr: string) => {\n    setEntries([\n      {\n        id: crypto.randomUUID(),\n        expr,\n        color: CURVE_COLORS[0]!,\n        bg: CURVE_BG[0]!,\n        valid: true,\n      },\n    ]);\n  }, []);\n\n  const resetView = React.useCallback(() => {\n    setXMin(initXMin);\n    setXMax(initXMax);\n  }, [initXMin, initXMax]);\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-col select-none overflow-hidden rounded-xl border border-border bg-background\",\n        className,\n      )}\n    >\n      <div className=\"flex flex-col gap-1.5 p-3 border-b border-border bg-muted/20\">\n        {entries.map((entry) => (\n          <div key={entry.id} className=\"flex items-center gap-2\">\n            <div\n              className=\"w-2.5 h-2.5 rounded-full shrink-0 ring-1 ring-white/10\"\n              style={{ backgroundColor: entry.color }}\n            />\n\n            <div className=\"relative flex-1\">\n              <input\n                value={entry.expr}\n                onChange={(ev) => updateEntry(entry.id, ev.target.value)}\n                placeholder=\"e.g.  sin(x) * 2\"\n                spellCheck={false}\n                autoComplete=\"off\"\n                className={cn(\n                  \"w-full h-8 bg-background border rounded-lg px-3 text-sm font-mono outline-none transition-all\",\n                  entry.expr && !entry.valid\n                    ? \"border-red-500/50 text-red-400 focus:border-red-400\"\n                    : \"border-border/60 focus:border-ring\",\n                )}\n              />\n            </div>\n\n            {entries.length > 1 && (\n              <button\n                onClick={() => removeEntry(entry.id)}\n                className=\"shrink-0 w-6 h-6 flex items-center justify-center rounded-md text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors text-xs\"\n              >\n                ✕\n              </button>\n            )}\n          </div>\n        ))}\n\n        <div className=\"flex items-center gap-1.5 flex-wrap pt-0.5\">\n          {entries.length < 4 && (\n            <button\n              onClick={addEntry}\n              className=\"text-xs text-muted-foreground hover:text-foreground transition-colors h-6 px-2 rounded-md hover:bg-muted\"\n            >\n              + function\n            </button>\n          )}\n          <div className=\"flex-1\" />\n          {DEFAULT_PRESETS.map((p) => (\n            <button\n              key={p}\n              onClick={() => applyPreset(p)}\n              className=\"text-xs h-6 px-2 rounded-md border border-border/60 bg-background hover:bg-muted transition-colors font-mono text-muted-foreground hover:text-foreground\"\n            >\n              {p}\n            </button>\n          ))}\n        </div>\n      </div>\n\n      <div\n        ref={(el) => {\n          measureRef(el);\n          (\n            graphDivRef as React.MutableRefObject<HTMLDivElement | null>\n          ).current = el;\n        }}\n        className=\"relative cursor-crosshair\"\n        style={{ height: 380 }}\n        onMouseDown={handleMouseDown}\n        onMouseMove={handleMouseMove}\n        onMouseUp={handleMouseUp}\n        onMouseLeave={handleMouseLeave}\n      >\n        <svg\n          width={width}\n          height={height}\n          className=\"absolute inset-0 block\"\n          style={{ overflow: \"visible\" }}\n        >\n          {showGrid && width > 0 && (\n            <g>\n              {xTicks.map((t) => (\n                <line\n                  key={`xg-${t}`}\n                  x1={toSX(t)}\n                  y1={0}\n                  x2={toSX(t)}\n                  y2={height}\n                  stroke=\"currentColor\"\n                  strokeWidth={0.5}\n                  className=\"text-border\"\n                  opacity={0.5}\n                />\n              ))}\n              {yTicks.map((t) => (\n                <line\n                  key={`yg-${t}`}\n                  x1={0}\n                  y1={toSY(t)}\n                  x2={width}\n                  y2={toSY(t)}\n                  stroke=\"currentColor\"\n                  strokeWidth={0.5}\n                  className=\"text-border\"\n                  opacity={0.5}\n                />\n              ))}\n            </g>\n          )}\n\n          {width > 0 && height > 0 && (\n            <g>\n              {axisY >= 0 && axisY <= height && (\n                <line\n                  x1={0}\n                  y1={axisY}\n                  x2={width}\n                  y2={axisY}\n                  stroke=\"currentColor\"\n                  strokeWidth={1.5}\n                  className=\"text-foreground/25\"\n                />\n              )}\n              {axisX >= 0 && axisX <= width && (\n                <line\n                  x1={axisX}\n                  y1={0}\n                  x2={axisX}\n                  y2={height}\n                  stroke=\"currentColor\"\n                  strokeWidth={1.5}\n                  className=\"text-foreground/25\"\n                />\n              )}\n            </g>\n          )}\n\n          {showLabels && width > 0 && height > 0 && (\n            <g\n              fontSize={10}\n              fontFamily=\"ui-monospace, monospace\"\n              className=\"text-muted-foreground\"\n            >\n              {xTicks\n                .filter((t) => Math.abs(t) > xStep * 0.01)\n                .map((t) => {\n                  const lx = toSX(t);\n                  if (lx < 4 || lx > width - 4) return null;\n                  const ly = Math.min(height - 4, Math.max(13, axisY + 14));\n                  return (\n                    <text\n                      key={`xl-${t}`}\n                      x={lx}\n                      y={ly}\n                      textAnchor=\"middle\"\n                      fill=\"currentColor\"\n                      opacity={0.55}\n                    >\n                      {fmtLabel(t)}\n                    </text>\n                  );\n                })}\n              {yTicks\n                .filter((t) => Math.abs(t) > yStep * 0.01)\n                .map((t) => {\n                  const ly = toSY(t);\n                  if (ly < 8 || ly > height - 4) return null;\n                  const lx = Math.min(width - 4, Math.max(4, axisX - 6));\n                  return (\n                    <text\n                      key={`yl-${t}`}\n                      x={lx}\n                      y={ly + 3}\n                      textAnchor=\"end\"\n                      fill=\"currentColor\"\n                      opacity={0.55}\n                    >\n                      {fmtLabel(t)}\n                    </text>\n                  );\n                })}\n            </g>\n          )}\n\n          <AnimatePresence>\n            {paths.map(({ id, d, color }) =>\n              d ? (\n                <motion.path\n                  key={`${id}-${zoomKey}`}\n                  d={d}\n                  fill=\"none\"\n                  stroke={color}\n                  strokeWidth={2.2}\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  initial={{ pathLength: 0, opacity: 0 }}\n                  animate={{ pathLength: 1, opacity: 1 }}\n                  exit={{ opacity: 0 }}\n                  transition={{\n                    pathLength: {\n                      duration: animated ? 0.7 : 0,\n                      ease: [0.4, 0, 0.2, 1],\n                    },\n                    opacity: { duration: 0.25 },\n                  }}\n                />\n              ) : null,\n            )}\n          </AnimatePresence>\n\n          {hover && (\n            <>\n              <line\n                x1={hover.svgX}\n                y1={0}\n                x2={hover.svgX}\n                y2={height}\n                stroke=\"currentColor\"\n                strokeWidth={1}\n                className=\"text-foreground/15\"\n                strokeDasharray=\"4 4\"\n              />\n              {paths.map(({ id, expr, color, valid }) => {\n                if (!valid || !expr) return null;\n                const y = evalMath(expr, hover.mathX);\n                if (y === null) return null;\n                const cy = toSY(y);\n                if (cy < -4 || cy > height + 4) return null;\n                return (\n                  <g key={id}>\n                    <circle\n                      cx={hover.svgX}\n                      cy={cy}\n                      r={5}\n                      fill={color}\n                      opacity={0.2}\n                    />\n                    <circle\n                      cx={hover.svgX}\n                      cy={cy}\n                      r={3}\n                      fill={color}\n                      stroke=\"white\"\n                      strokeWidth={1.5}\n                    />\n                  </g>\n                );\n              })}\n            </>\n          )}\n        </svg>\n\n        <AnimatePresence>\n          {hover && (\n            <motion.div\n              className=\"absolute pointer-events-none z-10 bg-background/90 backdrop-blur-sm border border-border/60 rounded-lg px-2.5 py-1.5 text-xs font-mono shadow-lg\"\n              style={{\n                left:\n                  hover.svgX > width * 0.68 ? hover.svgX - 12 : hover.svgX + 14,\n                top: Math.max(6, hover.svgY - 44),\n                transform:\n                  hover.svgX > width * 0.68 ? \"translateX(-100%)\" : undefined,\n              }}\n              initial={{ opacity: 0, scale: 0.92 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, scale: 0.92 }}\n              transition={{ duration: 0.1 }}\n            >\n              <div className=\"text-muted-foreground mb-1 text-[10px]\">\n                x = {hover.mathX.toFixed(3)}\n              </div>\n              {paths.map(({ id, expr, color, valid }) => {\n                if (!valid || !expr) return null;\n                const y = evalMath(expr, hover.mathX);\n                if (y === null) return null;\n                return (\n                  <div\n                    key={id}\n                    className=\"leading-snug text-[10px]\"\n                    style={{ color }}\n                  >\n                    f = {y.toFixed(4)}\n                  </div>\n                );\n              })}\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        <div className=\"absolute bottom-2 left-3 right-3 flex items-end justify-between pointer-events-none\">\n          <AnimatePresence>\n            {!isDefaultView && (\n              <motion.button\n                initial={{ opacity: 0, y: 4 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={{ opacity: 0, y: 4 }}\n                transition={{ duration: 0.15 }}\n                className=\"pointer-events-auto text-[10px] text-muted-foreground/70 hover:text-foreground transition-colors h-5 px-1.5 rounded bg-background/60 backdrop-blur-sm border border-border/40 hover:bg-muted\"\n                onClick={resetView}\n              >\n                reset view\n              </motion.button>\n            )}\n          </AnimatePresence>\n          <span className=\"text-[10px] text-muted-foreground/30 ml-auto\">\n            scroll to zoom · drag to pan\n          </span>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport { MathGraph, type MathGraphProps };\n",
      "type": "registry:ui",
      "target": "components/unlumen-ui/math-graph.tsx"
    },
    {
      "path": "registry/components/unlumen/math-graph/math-expression.ts",
      "content": "const MAX_EXPRESSION_LENGTH = 256;\nconst MAX_TOKENS = 128;\nconst MAX_DEPTH = 32;\n\ntype Token =\n  | { type: \"number\"; value: number }\n  | { type: \"identifier\"; value: string }\n  | { type: \"operator\"; value: \"+\" | \"-\" | \"*\" | \"/\" | \"^\" }\n  | { type: \"leftParen\" | \"rightParen\" | \"comma\" };\n\ntype Expression =\n  | { type: \"number\"; value: number }\n  | { type: \"variable\" }\n  | { type: \"unary\"; operator: \"+\" | \"-\"; value: Expression }\n  | {\n      type: \"binary\";\n      operator: \"+\" | \"-\" | \"*\" | \"/\" | \"^\";\n      left: Expression;\n      right: Expression;\n    }\n  | { type: \"call\"; name: keyof typeof FUNCTIONS; args: Expression[] };\n\nconst FUNCTIONS = {\n  sin: Math.sin,\n  cos: Math.cos,\n  tan: Math.tan,\n  asin: Math.asin,\n  acos: Math.acos,\n  atan: Math.atan,\n  atan2: Math.atan2,\n  sinh: Math.sinh,\n  cosh: Math.cosh,\n  tanh: Math.tanh,\n  sqrt: Math.sqrt,\n  cbrt: Math.cbrt,\n  abs: Math.abs,\n  log: Math.log,\n  log2: Math.log2,\n  log10: Math.log10,\n  exp: Math.exp,\n  pow: Math.pow,\n  floor: Math.floor,\n  ceil: Math.ceil,\n  round: Math.round,\n  min: Math.min,\n  max: Math.max,\n  sign: Math.sign,\n} as const;\n\nfunction tokenize(source: string): Token[] | null {\n  if (!source.trim() || source.length > MAX_EXPRESSION_LENGTH) return null;\n\n  const tokens: Token[] = [];\n  let position = 0;\n\n  while (position < source.length) {\n    const character = source[position]!;\n    if (/\\s/.test(character)) {\n      position += 1;\n      continue;\n    }\n\n    const rest = source.slice(position);\n    const number = /^(?:\\d+\\.?\\d*|\\.\\d+)(?:e[+-]?\\d+)?/i.exec(rest);\n    if (number) {\n      tokens.push({ type: \"number\", value: Number(number[0]) });\n      position += number[0].length;\n      continue;\n    }\n\n    const identifier = /^[A-Za-z]+/.exec(rest);\n    if (identifier) {\n      tokens.push({ type: \"identifier\", value: identifier[0] });\n      position += identifier[0].length;\n      continue;\n    }\n\n    if (character === \"*\") {\n      const isExponent = source[position + 1] === \"*\";\n      tokens.push({ type: \"operator\", value: isExponent ? \"^\" : \"*\" });\n      position += isExponent ? 2 : 1;\n      continue;\n    }\n    if (\n      character === \"+\" ||\n      character === \"-\" ||\n      character === \"/\" ||\n      character === \"^\"\n    ) {\n      tokens.push({ type: \"operator\", value: character });\n      position += 1;\n      continue;\n    }\n    if (character === \"(\") tokens.push({ type: \"leftParen\" });\n    else if (character === \")\") tokens.push({ type: \"rightParen\" });\n    else if (character === \",\") tokens.push({ type: \"comma\" });\n    else return null;\n    position += 1;\n\n    if (tokens.length > MAX_TOKENS) return null;\n  }\n\n  return tokens;\n}\n\nclass Parser {\n  private position = 0;\n\n  constructor(private readonly tokens: Token[]) {}\n\n  parse(): Expression | null {\n    const expression = this.parseSum(0);\n    return expression && this.position === this.tokens.length\n      ? expression\n      : null;\n  }\n\n  private peek(): Token | undefined {\n    return this.tokens[this.position];\n  }\n\n  private consume(type: Token[\"type\"]): Token | null {\n    const token = this.peek();\n    if (!token || token.type !== type) return null;\n    this.position += 1;\n    return token;\n  }\n\n  private parseSum(depth: number): Expression | null {\n    let expression = this.parseProduct(depth + 1);\n    while (expression) {\n      const token = this.peek();\n      if (\n        token?.type !== \"operator\" ||\n        (token.value !== \"+\" && token.value !== \"-\")\n      )\n        break;\n      this.position += 1;\n      const right = this.parseProduct(depth + 1);\n      if (!right) return null;\n      expression = {\n        type: \"binary\",\n        operator: token.value,\n        left: expression,\n        right,\n      };\n    }\n    return expression;\n  }\n\n  private parseProduct(depth: number): Expression | null {\n    let expression = this.parsePower(depth + 1);\n    while (expression) {\n      const token = this.peek();\n      if (\n        token?.type !== \"operator\" ||\n        (token.value !== \"*\" && token.value !== \"/\")\n      )\n        break;\n      this.position += 1;\n      const right = this.parsePower(depth + 1);\n      if (!right) return null;\n      expression = {\n        type: \"binary\",\n        operator: token.value,\n        left: expression,\n        right,\n      };\n    }\n    return expression;\n  }\n\n  private parsePower(depth: number): Expression | null {\n    const left = this.parseUnary(depth + 1);\n    if (!left) return null;\n    const token = this.peek();\n    if (token?.type !== \"operator\" || token.value !== \"^\") return left;\n    this.position += 1;\n    const right = this.parsePower(depth + 1);\n    return right ? { type: \"binary\", operator: \"^\", left, right } : null;\n  }\n\n  private parseUnary(depth: number): Expression | null {\n    if (depth > MAX_DEPTH) return null;\n    const token = this.peek();\n    if (\n      token?.type === \"operator\" &&\n      (token.value === \"+\" || token.value === \"-\")\n    ) {\n      this.position += 1;\n      const value = this.parseUnary(depth + 1);\n      return value ? { type: \"unary\", operator: token.value, value } : null;\n    }\n    return this.parsePrimary(depth + 1);\n  }\n\n  private parsePrimary(depth: number): Expression | null {\n    if (depth > MAX_DEPTH) return null;\n    const token = this.peek();\n    if (!token) return null;\n    if (token.type === \"number\") {\n      this.position += 1;\n      return { type: \"number\", value: token.value };\n    }\n    if (token.type === \"leftParen\") {\n      this.position += 1;\n      const expression = this.parseSum(depth + 1);\n      return expression && this.consume(\"rightParen\") ? expression : null;\n    }\n    if (token.type !== \"identifier\") return null;\n\n    this.position += 1;\n    if (token.value === \"x\") return { type: \"variable\" };\n    if (token.value === \"PI\") return { type: \"number\", value: Math.PI };\n    if (token.value === \"E\") return { type: \"number\", value: Math.E };\n    if (!(token.value in FUNCTIONS) || !this.consume(\"leftParen\")) return null;\n\n    const args: Expression[] = [];\n    if (this.peek()?.type !== \"rightParen\") {\n      while (true) {\n        const argument = this.parseSum(depth + 1);\n        if (!argument) return null;\n        args.push(argument);\n        if (!this.consume(\"comma\")) break;\n      }\n    }\n    return this.consume(\"rightParen\")\n      ? { type: \"call\", name: token.value as keyof typeof FUNCTIONS, args }\n      : null;\n  }\n}\n\nfunction evaluate(expression: Expression, x: number): number {\n  switch (expression.type) {\n    case \"number\":\n      return expression.value;\n    case \"variable\":\n      return x;\n    case \"unary\": {\n      const value = evaluate(expression.value, x);\n      return expression.operator === \"-\" ? -value : value;\n    }\n    case \"binary\": {\n      const left = evaluate(expression.left, x);\n      const right = evaluate(expression.right, x);\n      switch (expression.operator) {\n        case \"+\":\n          return left + right;\n        case \"-\":\n          return left - right;\n        case \"*\":\n          return left * right;\n        case \"/\":\n          return left / right;\n        case \"^\":\n          return left ** right;\n      }\n    }\n    case \"call\": {\n      const fn = FUNCTIONS[expression.name] as unknown as (\n        ...values: number[]\n      ) => number;\n      return fn(...expression.args.map((arg) => evaluate(arg, x)));\n    }\n  }\n}\n\nexport function evaluateMathExpression(\n  source: string,\n  x: number,\n): number | null {\n  const tokens = tokenize(source);\n  if (!tokens) return null;\n  const expression = new Parser(tokens).parse();\n  if (!expression) return null;\n  const result = evaluate(expression, x);\n  return Number.isFinite(result) ? result : null;\n}\n",
      "type": "registry:lib",
      "target": "components/unlumen-ui/math-expression.ts"
    }
  ],
  "type": "registry:ui"
}