import { renderLatex } from "@ofb/katex"; import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"; const VIEW_SIZE = 400; const UNIT = 20; const ORIGIN = VIEW_SIZE / 2; const X_RANGE = 10; const LINE_SAMPLE_STEP = 0.12; const INTERSECTION_HIT_SCREEN = 28; const LINE_HIT_SCREEN = 14; const HOVER_RADIUS = 8; const POINT_RADIUS = 5; type GraphHover = { kind: "intersection" | "line1" | "line2"; x: number; y: number; label: string; canvasX: number; canvasY: number; }; type SystemKind = "unique" | "parallel" | "coincident"; type Preset = { id: string; label: string; m1: number; c1: number; m2: number; c2: number; }; const PRESETS: Preset[] = [ { id: "unique", label: "One solution", m1: 1, c1: 1, m2: -1, c2: 5 }, { id: "parallel", label: "Parallel", m1: 2, c1: 1, m2: 2, c2: -3 }, { id: "coincident", label: "Coincident", m1: 1, c1: 2, m2: 1, c2: 2 }, ]; function round(n: number, digits = 2) { const p = 10 ** digits; return Math.round(n * p) / p; } function formatNum(n: number) { if (Number.isInteger(n)) return String(n); return round(n, 2).toString(); } function formatCoord(x: number, y: number) { return `(${formatNum(x)}, ${formatNum(y)})`; } function toCanvasX(x: number) { return ORIGIN + x * UNIT; } function toCanvasY(y: number) { return ORIGIN - y * UNIT; } function clamp(n: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, n)); } function pointerToCanvas( canvas: HTMLCanvasElement, clientX: number, clientY: number ) { const rect = canvas.getBoundingClientRect(); const scaleX = VIEW_SIZE / rect.width; const scaleY = VIEW_SIZE / rect.height; const px = (clientX - rect.left) * scaleX; const py = (clientY - rect.top) * scaleY; return { px, py }; } function distPx(px1: number, py1: number, px2: number, py2: number) { return Math.hypot(px1 - px2, py1 - py2); } function canvasHitRadius(canvas: HTMLCanvasElement, screenRadius: number) { const rect = canvas.getBoundingClientRect(); const scale = rect.width > 0 ? VIEW_SIZE / rect.width : 1; return screenRadius * scale; } function nearestLineHover( m: number, c: number, px: number, py: number ): { dist: number; x: number; y: number; canvasX: number; canvasY: number } | null { let bestX = 0; let bestY = 0; let bestDist = Infinity; for (let x = -X_RANGE; x <= X_RANGE; x += LINE_SAMPLE_STEP) { const y = m * x + c; if (y < -X_RANGE || y > X_RANGE) continue; const d = distPx(px, py, toCanvasX(x), toCanvasY(y)); if (d < bestDist) { bestDist = d; bestX = x; bestY = y; } } if (!Number.isFinite(bestDist)) return null; return { dist: bestDist, x: bestX, y: bestY, canvasX: toCanvasX(bestX), canvasY: toCanvasY(bestY), }; } function hitTestHover( canvas: HTMLCanvasElement, m1: number, c1: number, m2: number, c2: number, meet: { x: number; y: number } | null, px: number, py: number ): GraphHover | null { const intersectionRadius = canvasHitRadius(canvas, INTERSECTION_HIT_SCREEN); const lineRadius = canvasHitRadius(canvas, LINE_HIT_SCREEN); if (meet) { const cx = toCanvasX(meet.x); const cy = toCanvasY(meet.y); if ( meet.x >= -X_RANGE && meet.x <= X_RANGE && meet.y >= -X_RANGE && meet.y <= X_RANGE && distPx(px, py, cx, cy) < intersectionRadius ) { return { kind: "intersection", x: meet.x, y: meet.y, label: "Intersection", canvasX: cx, canvasY: cy, }; } } const near1 = nearestLineHover(m1, c1, px, py); const near2 = nearestLineHover(m2, c2, px, py); const candidates: Array<{ dist: number; hover: GraphHover }> = []; if (near1 && near1.dist < lineRadius) { candidates.push({ dist: near1.dist, hover: { kind: "line1", x: near1.x, y: near1.y, label: "Line 1", canvasX: near1.canvasX, canvasY: near1.canvasY, }, }); } if (near2 && near2.dist < lineRadius) { candidates.push({ dist: near2.dist, hover: { kind: "line2", x: near2.x, y: near2.y, label: "Line 2", canvasX: near2.canvasX, canvasY: near2.canvasY, }, }); } if (candidates.length === 0) return null; candidates.sort((a, b) => a.dist - b.dist); return candidates[0].hover; } function classify(m1: number, c1: number, m2: number, c2: number): SystemKind { if (Math.abs(m1 - m2) < 1e-9) { return Math.abs(c1 - c2) < 1e-9 ? "coincident" : "parallel"; } return "unique"; } function intersection( m1: number, c1: number, m2: number, c2: number ): { x: number; y: number } | null { const kind = classify(m1, c1, m2, c2); if (kind !== "unique") return null; const x = (c2 - c1) / (m1 - m2); const y = m1 * x + c1; return { x, y }; } function readCanvasColors(canvas: HTMLCanvasElement) { const style = getComputedStyle(canvas); return { grid: style.getPropertyValue("--math-grid").trim() || "#e5e5e5", axis: style.getPropertyValue("--math-axis").trim() || "#a8a29e", line1: style.getPropertyValue("--math-curve").trim() || "#9a6b2e", line2: style.getPropertyValue("--math-line2").trim() || "#2563eb", intersection: style.getPropertyValue("--math-root").trim() || "#2563eb", bg: style.getPropertyValue("--math-canvas-bg").trim() || "#ffffff", }; } function drawGraph( canvas: HTMLCanvasElement, m1: number, c1: number, m2: number, c2: number, point: { x: number; y: number } | null, hover: GraphHover | null ) { const ctx = canvas.getContext("2d"); if (!ctx) return; const dpr = window.devicePixelRatio || 1; canvas.width = VIEW_SIZE * dpr; canvas.height = VIEW_SIZE * dpr; canvas.style.width = `${VIEW_SIZE}px`; canvas.style.height = `${VIEW_SIZE}px`; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const colors = readCanvasColors(canvas); ctx.fillStyle = colors.bg; ctx.fillRect(0, 0, VIEW_SIZE, VIEW_SIZE); ctx.strokeStyle = colors.grid; ctx.lineWidth = 1; for (let i = -X_RANGE; i <= X_RANGE; i++) { ctx.beginPath(); ctx.moveTo(toCanvasX(i), 0); ctx.lineTo(toCanvasX(i), VIEW_SIZE); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, toCanvasY(i)); ctx.lineTo(VIEW_SIZE, toCanvasY(i)); ctx.stroke(); } ctx.strokeStyle = colors.axis; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(ORIGIN, 0); ctx.lineTo(ORIGIN, VIEW_SIZE); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, ORIGIN); ctx.lineTo(VIEW_SIZE, ORIGIN); ctx.stroke(); const drawLine = ( m: number, c: number, color: string, width: number, active: boolean ) => { ctx.strokeStyle = color; ctx.lineWidth = active ? width + 1.5 : width; ctx.lineCap = "round"; ctx.beginPath(); let started = false; for (let x = -X_RANGE; x <= X_RANGE; x += 0.1) { const y = m * x + c; if (y < -X_RANGE || y > X_RANGE) continue; const px = toCanvasX(x); const py = toCanvasY(y); if (!started) { ctx.moveTo(px, py); started = true; } else { ctx.lineTo(px, py); } } ctx.stroke(); }; drawLine(m1, c1, colors.line1, 3, hover?.kind === "line1"); drawLine(m2, c2, colors.line2, 3, hover?.kind === "line2"); const drawMarker = (cx: number, cy: number, fill: string, active: boolean) => { const r = active ? HOVER_RADIUS : POINT_RADIUS; ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill(); if (active) { ctx.strokeStyle = colors.bg; ctx.lineWidth = 2; ctx.stroke(); } }; if (point && point.x >= -X_RANGE && point.x <= X_RANGE && point.y >= -X_RANGE && point.y <= X_RANGE) { const cx = toCanvasX(point.x); const cy = toCanvasY(point.y); const active = hover?.kind === "intersection"; drawMarker(cx, cy, colors.intersection, active); } if (hover?.kind === "line1") { drawMarker(hover.canvasX, hover.canvasY, colors.line1, true); } if (hover?.kind === "line2") { drawMarker(hover.canvasX, hover.canvasY, colors.line2, true); } } function SliderRow(props: { id: string; label: string; value: number; min: number; max: number; step: number; accentClass?: string; onInput: (v: number) => void; }) { return (
props.onInput(Number(e.currentTarget.value))} />
); } function natureLabel(kind: SystemKind) { if (kind === "unique") return "Exactly one solution (lines intersect)"; if (kind === "parallel") return "No solution (parallel lines)"; return "Infinitely many solutions (coincident lines)"; } function SimultaneousGraphExplorer() { const [m1, setM1] = createSignal(1); const [c1, setC1] = createSignal(1); const [m2, setM2] = createSignal(-1); const [c2, setC2] = createSignal(5); const [activePreset, setActivePreset] = createSignal(null); const [hover, setHover] = createSignal(null); const [tooltipPos, setTooltipPos] = createSignal({ left: 0, top: 0 }); let canvasRef: HTMLCanvasElement | undefined; let wrapRef: HTMLDivElement | undefined; const kind = createMemo(() => classify(m1(), c1(), m2(), c2())); const meet = createMemo(() => intersection(m1(), c1(), m2(), c2())); const eq1Html = createMemo(() => renderLatex(`y = ${formatNum(m1())}x + ${formatNum(c1())}`) ); const eq2Html = createMemo(() => renderLatex(`y = ${formatNum(m2())}x + ${formatNum(c2())}`) ); const applyPreset = (p: Preset) => { setM1(p.m1); setC1(p.c1); setM2(p.m2); setC2(p.c2); setActivePreset(p.id); }; const onSliderChange = (setter: (v: number) => void) => (v: number) => { setActivePreset(null); setter(v); }; const redraw = () => { if (canvasRef) { drawGraph(canvasRef, m1(), c1(), m2(), c2(), meet(), hover()); } }; createEffect(() => { m1(); c1(); m2(); c2(); meet(); hover(); redraw(); }); const updateTooltipPosition = (clientX: number, clientY: number) => { if (!wrapRef) return; const rect = wrapRef.getBoundingClientRect(); const pad = 12; let left = clientX - rect.left + pad; let top = clientY - rect.top - pad; const maxLeft = rect.width - 140; const maxTop = rect.height - 48; left = clamp(left, pad, maxLeft); top = clamp(top, pad, maxTop); setTooltipPos({ left, top }); }; createEffect(() => { const canvas = canvasRef; if (!canvas) return; const onMove = (e: PointerEvent) => { const { px, py } = pointerToCanvas(canvas, e.clientX, e.clientY); const hit = hitTestHover(canvas, m1(), c1(), m2(), c2(), meet(), px, py); setHover(hit); if (hit) { updateTooltipPosition(e.clientX, e.clientY); canvas.setAttribute("aria-label", `${hit.label} at ${formatCoord(hit.x, hit.y)}`); } else { canvas.setAttribute( "aria-label", "Graph of two lines. Hover a line or intersection to read coordinates." ); } }; const onLeave = () => { setHover(null); canvas.setAttribute( "aria-label", "Graph of two lines. Hover a line or intersection to read coordinates." ); }; canvas.addEventListener("pointermove", onMove); canvas.addEventListener("pointerleave", onLeave); canvas.addEventListener("pointerdown", onMove); onCleanup(() => { canvas.removeEventListener("pointermove", onMove); canvas.removeEventListener("pointerleave", onLeave); canvas.removeEventListener("pointerdown", onMove); }); }); return (

Simultaneous equations graph explorer

{PRESETS.map((p) => ( ))}

Line 1

Line 2

Line 1 (y = m₁x + c₁)

Line 2 (y = m₂x + c₂)

System

{natureLabel(kind())}

{(p) => (

Intersection at {formatCoord(p().x, p().y)}

)}
{ wrapRef = el; }} > { canvasRef = el; }} class="math-widget__canvas" classList={{ "math-widget__canvas--active": !!hover() }} width={VIEW_SIZE} height={VIEW_SIZE} role="img" aria-label="Graph of two lines. Hover a line or intersection to read coordinates." /> {(h) => (
{h().label} {formatCoord(h().x, h().y)}
)}
Hover a line to read coordinates along it; near an intersection the cursor snaps to the solution point. Grid: −10 to 10 on both axes.
); } export default SimultaneousGraphExplorer;