(function () { const $graph = (selector, root = document) => root.querySelector(selector); const COLORS = [ "#196548", "#d86f45", "#5d73e8", "#8667b5", "#c44f83", "#198b9a", "#b07b24", "#3d4852", ]; const graph = { functions: [], nextId: 1, viewport: { xMin: -10, xMax: 10, yMin: -6, yMax: 6 }, dragging: null, frame: null, initialized: false, observer: null, }; function schedule() { if (graph.frame) window.cancelAnimationFrame(graph.frame); graph.frame = window.requestAnimationFrame(() => { graph.frame = null; draw(); }); } function activeFunctions() { return graph.functions.filter( (item) => item.visible && item.expression.trim(), ); } function addFunction(expression = "", color) { if (graph.functions.length >= 8) { showToast("最多同时绘制 8 条曲线"); return; } graph.functions.push({ id: graph.nextId, expression, color: color || COLORS[(graph.nextId - 1) % COLORS.length], visible: true, }); graph.nextId += 1; renderFunctionList(); schedule(); } function renderFunctionList() { const root = $graph("#graph-function-list"); root.replaceChildren( ...graph.functions.map((item, index) => { const row = document.createElement("div"); row.className = "graph-function-row"; const visible = document.createElement("input"); visible.type = "checkbox"; visible.checked = item.visible; visible.setAttribute("aria-label", `显示曲线 ${index + 1}`); visible.addEventListener("change", () => { item.visible = visible.checked; schedule(); }); const color = document.createElement("input"); color.type = "color"; color.value = item.color; color.setAttribute("aria-label", `曲线 ${index + 1} 颜色`); color.addEventListener("input", () => { item.color = color.value; schedule(); }); const prefix = document.createElement("span"); prefix.textContent = `f${index + 1}(x)`; const input = document.createElement("input"); input.className = "formula-input"; input.value = item.expression; input.placeholder = "例如 sin(x)"; input.setAttribute("aria-label", `函数 ${index + 1}`); input.addEventListener("input", () => { item.expression = input.value; schedule(); }); const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "×"; remove.title = "删除曲线"; remove.disabled = graph.functions.length === 1; remove.addEventListener("click", () => { graph.functions = graph.functions.filter( (candidate) => candidate.id !== item.id, ); renderFunctionList(); schedule(); }); row.append(visible, color, prefix, input, remove); return row; }), ); } function readViewport() { const viewport = { xMin: Number($graph("#graph-x-min").value), xMax: Number($graph("#graph-x-max").value), yMin: Number($graph("#graph-y-min").value), yMax: Number($graph("#graph-y-max").value), }; if ( !Object.values(viewport).every(Number.isFinite) || viewport.xMin >= viewport.xMax || viewport.yMin >= viewport.yMax || viewport.xMax - viewport.xMin > 1_000_000 || viewport.yMax - viewport.yMin > 1_000_000 ) { throw new Error("坐标范围必须有限、最小值小于最大值,跨度不超过 1,000,000"); } return viewport; } function writeViewport(viewport = graph.viewport) { $graph("#graph-x-min").value = Number(viewport.xMin.toPrecision(8)); $graph("#graph-x-max").value = Number(viewport.xMax.toPrecision(8)); $graph("#graph-y-min").value = Number(viewport.yMin.toPrecision(8)); $graph("#graph-y-max").value = Number(viewport.yMax.toPrecision(8)); } function sampleFunction(item, viewport, count) { const parameter = Number($graph("#graph-parameter").value); const samples = []; let lastError = null; for (let index = 0; index <= count; index += 1) { const x = viewport.xMin + (index / count) * (viewport.xMax - viewport.xMin); let y = Number.NaN; try { y = evaluateExpression(item.expression, { x, a: parameter }); } catch (error) { lastError = error; } samples.push({ x, y: Number.isFinite(y) ? y : Number.NaN }); } if (!samples.some((point) => Number.isFinite(point.y))) { throw lastError || new Error(`函数 ${item.expression} 在当前视窗没有有效值`); } return samples; } function autoY(functions, viewport) { const values = functions .flatMap((item) => sampleFunction(item, viewport, 500)) .map((item) => item.y) .filter(Number.isFinite) .sort((left, right) => left - right); if (!values.length) return { ...viewport, yMin: -6, yMax: 6 }; const low = values[Math.floor(values.length * 0.02)]; const high = values[Math.min(values.length - 1, Math.ceil(values.length * 0.98))]; const span = Math.max(high - low, Math.abs(high) * 0.1, 2); return { ...viewport, yMin: low - span * 0.12, yMax: high + span * 0.12, }; } function niceStep(span, target = 9) { const rough = span / target; const power = 10 ** Math.floor(Math.log10(rough)); const fraction = rough / power; return (fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10) * power; } function tickLabel(value, step) { if (Math.abs(value) < step * 0.001) return "0"; if (Math.abs(value) >= 10000 || Math.abs(value) < 0.001) { return value.toExponential(1); } return value.toFixed(Math.min(6, Math.max(0, -Math.floor(Math.log10(step))))); } function canvasSize() { const canvas = $graph("#graph-canvas"); const rect = canvas.getBoundingClientRect(); const ratio = Math.min(window.devicePixelRatio || 1, 2); const width = Math.max(320, Math.round(rect.width)); const height = Math.max(320, Math.round(rect.height)); if ( canvas.width !== Math.round(width * ratio) || canvas.height !== Math.round(height * ratio) ) { canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); } const context = canvas.getContext("2d"); context.setTransform(ratio, 0, 0, ratio, 0, 0); return { context, width, height }; } function drawGrid(context, viewport, width, height, toX, toY) { context.fillStyle = "#fcfcf8"; context.fillRect(0, 0, width, height); if (!$graph("#graph-grid").checked) return; const xStep = niceStep(viewport.xMax - viewport.xMin); const yStep = niceStep(viewport.yMax - viewport.yMin); context.font = "11px system-ui, sans-serif"; context.lineWidth = 1; context.strokeStyle = "#e1e6e0"; context.fillStyle = "#6d796f"; context.textAlign = "center"; context.textBaseline = "top"; for ( let value = Math.ceil(viewport.xMin / xStep) * xStep; value <= viewport.xMax + xStep * 0.001; value += xStep ) { const x = toX(value); context.beginPath(); context.moveTo(x, 0); context.lineTo(x, height); context.stroke(); const labelY = viewport.yMin <= 0 && viewport.yMax >= 0 ? Math.min(height - 18, Math.max(4, toY(0) + 6)) : height - 18; context.fillText(tickLabel(value, xStep), x, labelY); } context.textAlign = "left"; context.textBaseline = "middle"; for ( let value = Math.ceil(viewport.yMin / yStep) * yStep; value <= viewport.yMax + yStep * 0.001; value += yStep ) { const y = toY(value); context.beginPath(); context.moveTo(0, y); context.lineTo(width, y); context.stroke(); const labelX = viewport.xMin <= 0 && viewport.xMax >= 0 ? Math.min(width - 50, Math.max(6, toX(0) + 7)) : 7; context.fillText(tickLabel(value, yStep), labelX, y); } } function drawAxes(context, viewport, width, height, toX, toY) { context.strokeStyle = "#263b30"; context.fillStyle = "#263b30"; context.lineWidth = 2; context.beginPath(); if (viewport.yMin <= 0 && viewport.yMax >= 0) { const y = toY(0); context.moveTo(0, y); context.lineTo(width, y); context.moveTo(width - 9, y - 5); context.lineTo(width, y); context.lineTo(width - 9, y + 5); } if (viewport.xMin <= 0 && viewport.xMax >= 0) { const x = toX(0); context.moveTo(x, height); context.lineTo(x, 0); context.moveTo(x - 5, 9); context.lineTo(x, 0); context.lineTo(x + 5, 9); } context.stroke(); context.font = "700 12px system-ui, sans-serif"; context.fillText("x", width - 16, Math.min(height - 16, Math.max(14, toY(0) - 15))); context.fillText("y", Math.min(width - 18, Math.max(10, toX(0) + 10)), 14); } function drawPath(context, samples, color, viewport, toX, toY) { const ySpan = viewport.yMax - viewport.yMin; context.strokeStyle = color; context.lineWidth = 2.6; context.lineJoin = "round"; context.beginPath(); let drawing = false; let previous; samples.forEach((item) => { const discontinuity = previous && Number.isFinite(previous.y) && Number.isFinite(item.y) && Math.abs(item.y - previous.y) > ySpan * 3; if ( !Number.isFinite(item.y) || item.y < viewport.yMin - ySpan || item.y > viewport.yMax + ySpan || discontinuity ) { drawing = false; } else if (!drawing) { context.moveTo(toX(item.x), toY(item.y)); drawing = true; } else { context.lineTo(toX(item.x), toY(item.y)); } previous = item; }); context.stroke(); } function analyze(samples, xSpan) { const roots = []; let extrema = 0; for (let index = 1; index < samples.length; index += 1) { const before = samples[index - 1]; const current = samples[index]; if (!Number.isFinite(before.y) || !Number.isFinite(current.y)) continue; if (before.y === 0 || before.y * current.y < 0) { const root = (before.x + current.x) / 2; if (!roots.length || Math.abs(root - roots.at(-1)) > xSpan / 300) { roots.push(root); } } if (index > 1) { const older = samples[index - 2]; if (!Number.isFinite(older.y)) continue; if ((before.y - older.y) * (current.y - before.y) < 0) extrema += 1; } } return { roots: roots.slice(0, 8), extrema }; } function renderLegend(functions) { $graph("#graph-legend").replaceChildren( ...functions.map((item) => { const badge = document.createElement("span"); const dot = document.createElement("i"); dot.style.background = item.color; badge.append(dot, document.createTextNode(item.expression)); return badge; }), ); } function draw() { const canvas = $graph("#graph-canvas"); if (!canvas) return; const functions = activeFunctions(); const { context, width, height } = canvasSize(); $graph("#graph-error").textContent = ""; $graph("#graph-parameter-label").textContent = Number($graph("#graph-parameter").value).toFixed(1); try { let viewport = readViewport(); if ($graph("#graph-auto-y").checked && functions.length) { viewport = autoY(functions, viewport); $graph("#graph-y-min").value = Number(viewport.yMin.toPrecision(7)); $graph("#graph-y-max").value = Number(viewport.yMax.toPrecision(7)); } graph.viewport = viewport; const toX = (x) => ((x - viewport.xMin) / (viewport.xMax - viewport.xMin)) * width; const toY = (y) => height - ((y - viewport.yMin) / (viewport.yMax - viewport.yMin)) * height; drawGrid(context, viewport, width, height, toX, toY); drawAxes(context, viewport, width, height, toX, toY); if (!functions.length) { $graph("#graph-error").textContent = "请至少输入并启用一个函数"; renderLegend([]); return; } const count = Math.max(500, Math.min(1600, Math.round(width * 1.5))); const sampled = functions.map((item) => ({ item, samples: sampleFunction(item, viewport, count), })); const first = sampled[0].samples; if ($graph("#graph-integral").checked) { context.fillStyle = `${functions[0].color}22`; context.beginPath(); context.moveTo(toX(viewport.xMin), toY(0)); first.forEach((point) => { if (Number.isFinite(point.y)) context.lineTo(toX(point.x), toY(point.y)); }); context.lineTo(toX(viewport.xMax), toY(0)); context.closePath(); context.fill(); } sampled.forEach(({ item, samples }) => { drawPath(context, samples, item.color, viewport, toX, toY); }); if ($graph("#graph-derivative").checked) { const derivative = first.slice(1, -1).map((point, index) => { const before = first[index]; const after = first[index + 2]; return { x: point.x, y: (after.y - before.y) / (after.x - before.x) }; }); context.setLineDash([8, 6]); drawPath(context, derivative, "#111827", viewport, toX, toY); context.setLineDash([]); } const analysis = analyze(first, viewport.xMax - viewport.xMin); const rootText = analysis.roots.length ? analysis.roots.map((item) => item.toPrecision(4)).join("、") : "当前视窗未发现"; $graph("#graph-analysis").textContent = `第一条曲线:近似零点 ${rootText};检测到 ${analysis.extrema} 个极值转折。`; renderLegend(functions); } catch (error) { context.clearRect(0, 0, width, height); $graph("#graph-error").textContent = error.message; } } function setViewport(viewport, disableAutoY = true) { graph.viewport = viewport; if (disableAutoY) $graph("#graph-auto-y").checked = false; writeViewport(viewport); schedule(); } function resetViewport() { $graph("#graph-auto-y").checked = true; setViewport({ xMin: -10, xMax: 10, yMin: -6, yMax: 6 }, false); } function autoFit() { $graph("#graph-auto-y").checked = true; schedule(); } function bindCanvasNavigation() { const canvas = $graph("#graph-canvas"); canvas.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; graph.dragging = { x: event.clientX, y: event.clientY, viewport: { ...graph.viewport }, }; canvas.setPointerCapture(event.pointerId); canvas.classList.add("dragging"); }); canvas.addEventListener("pointermove", (event) => { if (!graph.dragging) return; const rect = canvas.getBoundingClientRect(); const start = graph.dragging; const dx = ((event.clientX - start.x) / rect.width) * (start.viewport.xMax - start.viewport.xMin); const dy = ((event.clientY - start.y) / rect.height) * (start.viewport.yMax - start.viewport.yMin); setViewport({ xMin: start.viewport.xMin - dx, xMax: start.viewport.xMax - dx, yMin: start.viewport.yMin + dy, yMax: start.viewport.yMax + dy, }); }); const stopDragging = () => { graph.dragging = null; canvas.classList.remove("dragging"); }; canvas.addEventListener("pointerup", stopDragging); canvas.addEventListener("pointercancel", stopDragging); canvas.addEventListener( "wheel", (event) => { event.preventDefault(); const rect = canvas.getBoundingClientRect(); const viewport = graph.viewport; const xRatio = (event.clientX - rect.left) / rect.width; const yRatio = 1 - (event.clientY - rect.top) / rect.height; const centerX = viewport.xMin + xRatio * (viewport.xMax - viewport.xMin); const centerY = viewport.yMin + yRatio * (viewport.yMax - viewport.yMin); const factor = event.deltaY > 0 ? 1.14 : 0.88; setViewport({ xMin: centerX + (viewport.xMin - centerX) * factor, xMax: centerX + (viewport.xMax - centerX) * factor, yMin: centerY + (viewport.yMin - centerY) * factor, yMax: centerY + (viewport.yMax - centerY) * factor, }); }, { passive: false }, ); canvas.addEventListener("dblclick", autoFit); } function init() { if (graph.initialized) return; graph.initialized = true; addFunction("sin(x) + a*x/3", COLORS[0]); addFunction("0.08*x^2 - 2", COLORS[1]); $graph("#graph-add-function").addEventListener("click", () => addFunction()); $graph("#graph-run").addEventListener("click", draw); $graph("#graph-auto-fit").addEventListener("click", autoFit); $graph("#graph-reset-view").addEventListener("click", resetViewport); [ "#graph-parameter", "#graph-x-min", "#graph-x-max", "#graph-auto-y", "#graph-grid", "#graph-derivative", "#graph-integral", ].forEach((selector) => { $graph(selector).addEventListener("input", schedule); }); ["#graph-y-min", "#graph-y-max"].forEach((selector) => { $graph(selector).addEventListener("input", () => { $graph("#graph-auto-y").checked = false; schedule(); }); }); bindCanvasNavigation(); if ("ResizeObserver" in window) { graph.observer = new ResizeObserver(schedule); graph.observer.observe($graph(".graph-stage")); } else { window.addEventListener("resize", schedule); } schedule(); } window.HuluGraph = { init, draw, resize: schedule, addFunction }; })();