Plot an equation you describe in plain English.
import React, { useEffect, useRef, useState } from "react"
import { createRoot } from "react-dom/client"
import * as echarts from "echarts"
import { compile, parse } from "mathjs"
import katex from "katex"
interface Equation {
label: string
expression: string
variable: string
range: [number, number]
steps?: number
}
interface Insight {
title: string
xLabel: string
yLabel: string
equations: Equation[]
explanation?: string
}
const cssVar = (name: string) =>
getComputedStyle(document.documentElement)
.getPropertyValue(`--chart-${name}`).trim()
const COLORS = ["#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#ec4899"]
function exprToHtml(expression: string): string {
try {
const tex = parse(expression).toTex()
return katex.renderToString(tex, { throwOnError: false })
} catch {
return expression
}
}
function formatNum(n: number): string {
if (n === 0) return "0"
const abs = Math.abs(n)
if (abs >= 0.001 && abs < 1e9)
return n.toLocaleString(undefined, { maximumSignificantDigits: 4 })
return n.toPrecision(4)
}
function evaluate(eq: Equation): { x: number[]; y: number[] } {
const expr = compile(eq.expression)
const steps = eq.steps ?? 200
const [lo, hi] = eq.range
const dx = (hi - lo) / steps
const x: number[] = []
const y: number[] = []
for (let i = 0; i <= steps; i++) {
const xVal = lo + i * dx
try {
const yVal = expr.evaluate({ [eq.variable]: xVal })
if (typeof yVal === "number" && isFinite(yVal)) {
x.push(xVal)
y.push(yVal)
}
} catch { /* skip undefined points */ }
}
return { x, y }
}
function buildChartOptions(insight: Insight, width: number): echarts.EChartsOption {
const small = width < 400
const series: echarts.SeriesOption[] = insight.equations.map((eq, i) => {
const { x, y } = evaluate(eq)
return {
name: eq.label,
type: "line",
showSymbol: false,
smooth: false,
data: x.map((xv, j) => [xv, y[j]]),
lineStyle: { width: 2, color: COLORS[i % COLORS.length] },
itemStyle: { color: COLORS[i % COLORS.length] },
animationDuration: 1000,
animationEasing: "cubicOut",
}
})
return {
backgroundColor: "transparent",
tooltip: {
trigger: "axis",
formatter: (params: any) => {
const lines = params.map((p: any) =>
`<span style="color:${p.color}">●</span> ${p.seriesName}: <strong>${formatNum(Number(p.value[1]))}</strong>`
)
return `${insight.equations[0].variable} = ${formatNum(Number(params[0].value[0]))}<br/>` + lines.join("<br/>")
},
},
legend: insight.equations.length > 1 ? {
bottom: 0,
textStyle: { color: cssVar("text-muted") },
} : undefined,
grid: { left: small ? "18%" : "12%", right: "5%", bottom: insight.equations.length > 1 ? "15%" : "10%", top: "10%" },
xAxis: {
type: "value",
name: insight.xLabel,
nameLocation: "center",
nameGap: 30,
nameTextStyle: { color: cssVar("text-muted"), fontSize: 12 },
axisLabel: { color: cssVar("text-muted") },
axisLine: { lineStyle: { color: cssVar("line") } },
splitLine: { lineStyle: { color: cssVar("split"), opacity: 0.3 } },
},
yAxis: {
type: "value",
name: insight.yLabel,
nameLocation: "center",
nameGap: 45,
nameTextStyle: { color: cssVar("text-muted"), fontSize: 12 },
axisLabel: { color: cssVar("text-muted") },
axisLine: { lineStyle: { color: cssVar("line") } },
splitLine: { lineStyle: { color: cssVar("split"), opacity: 0.3 } },
},
series,
}
}
function App() {
const ref = useRef<HTMLDivElement>(null)
const [insight, setInsight] = useState<Insight | null>(() => {
try { return tb.insight<Insight>() } catch { return null }
})
const [isGenerating, setIsGenerating] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleGenerate = async () => {
setIsGenerating(true)
setError(null)
try {
const result = await tb.infer<Insight>()
if (result) {
setInsight(result)
}
} catch (e: any) {
setError(e.message ?? "Inference failed")
} finally {
setIsGenerating(false)
}
}
useEffect(() => {
if (!insight || !ref.current) return
const el = ref.current
const chart = echarts.init(el)
const update = () => {
try {
chart.setOption(buildChartOptions(insight, el.offsetWidth))
setError(null)
} catch (e: any) {
setError(`Chart error: ${e.message}`)
}
}
update()
const mo = new MutationObserver(update)
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] })
const onResize = () => { chart.resize(); update() }
window.addEventListener("resize", onResize)
return () => { chart.dispose(); mo.disconnect(); window.removeEventListener("resize", onResize) }
}, [insight])
return (
<div className="container">
{!insight && (
<div className="empty">
<h1>Equation Plot</h1>
<p>Describe a question or equation in the Data tab, then click Generate.</p>
<button className={`generate-btn ${isGenerating ? "loading" : ""}`} onClick={handleGenerate}>
{isGenerating ? "Generating..." : "Generate Plot"}
</button>
</div>
)}
{insight && (
<>
<button className={`generate-btn regen ${isGenerating ? "loading" : ""}`} onClick={handleGenerate}>
{isGenerating ? "GENERATING..." : "PLOT NEW EQUATION"}
</button>
<h1 className="title">{insight.title}</h1>
<div className="equations">
{insight.equations.map((eq, i) => (
<div key={i} className="eq" style={{ borderLeftColor: COLORS[i % COLORS.length] }}>
<span className="eq-label">{eq.label}:</span>{" "}
<span dangerouslySetInnerHTML={{ __html: exprToHtml(eq.expression) }} />
</div>
))}
</div>
<div ref={ref} className="chart-container" />
{insight.explanation && (
<div className="explanation">
<p>{insight.explanation}</p>
</div>
)}
</>
)}
{error && <p className="error">{error}</p>}
</div>
)
}
createRoot(document.getElementById("root")!).render(<App />)