匿名鱼油 发表于 2025-8-14 18:44:02

二次函数可视化

二次函数可视化



交互式的二次函数 y=ax^2+bx+c 可视化工具。可以在右侧面板调节我们 a、b、c(范围 ±10)
画布中央的坐标系会实时显示对应抛物线的变化。



特点:
还有一个下载的按钮,可以用png的形式记录下当下的函数图像。


<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>二次函数可视化</title>
<style>
    :root{
      --bg:#eaf6f8;
      --panel:#ffffff;
      --ink:#223;
      --muted:#5b6573;
      --grid:#dfe7ec;
      --axes:#3b4856;
      --accent:#cd671f;   /* 曲线颜色 */
      --axisAccent:#b046ff; /* 对称轴 */
      --vertex:#7a2fff;   /* 顶点 */
      --root:#1f7ae0;   /* 根 */
      --shadow: 0 6px 18px rgba(0,0,0,.08);
      --radius:12px;
    }
    *{box-sizing:border-box}
    html,body{height:100%}
    body{
      margin:0;
      font: 14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, "PingFang SC", "Microsoft YaHei", "Noto Sans", Arial, sans-serif;
      color:var(--ink);
      background:var(--bg);
    }
    .app{
      min-height:100vh;
      padding:16px;
      display:flex;
      gap:18px;
      align-items:stretch;
      justify-content:center;
    }
    /* 左侧:画布区域 */
    .canvas-col{
      flex:1 1 640px;
      display:flex;
      align-items:center;
      justify-content:center;
    }
    .canvas-card{
      width:100%;
      max-width:900px;
      background:var(--panel);
      border-radius:var(--radius);
      box-shadow:var(--shadow);
      padding:12px;
    }
    /* 让画布保持 4:3 比例且自适应 */
    .canvas-wrap{
      position:relative;
      width:100%;
      aspect-ratio: 4/3;
      min-height: 300px;
    }
    canvas#graph{
      position:absolute;
      inset:0;
      width:100%;
      height:100%;
      display:block;
      border-radius:8px;
      background:#fff;
    }

    /* 右侧:控制面板 */
    .panel{
      flex:0 0 340px;
      background:var(--panel);
      border-radius:var(--radius);
      box-shadow:var(--shadow);
      padding:16px;
      display:flex;
      flex-direction:column;
      gap:14px;
      max-height:calc(100vh - 32px);
      overflow:auto;
    }
    .title{
      border:1px solid #e8eef3;
      border-radius:10px;
      padding:12px;
      background:#fbfdff;
    }
    .title .formula{
      font-size:16px;
      font-weight:600;
      letter-spacing:.2px;
    }
    .title .live{
      margin-top:6px;
      font-size:18px;
      font-weight:700;
    }
    .live .code{
      display:inline-block;
      padding:2px 6px;
      border-radius:8px;
      background:#f2f6ff;
    }

    .controls{
      display:grid;
      grid-template-columns: 1fr;
      gap:10px;
    }
    .row{
      display:grid;
      grid-template-columns: 56px 1fr 90px;
      gap:10px;
      align-items:center;
    }
    .row.wide{
      grid-template-columns: 110px 1fr 90px;
    }
    label.key{
      color:var(--muted);
      font-weight:600;
    }
    input{
      width:100%;
      appearance:none;
      height:6px;
      border-radius:999px;
      background:#e8eef3;
      outline:none;
    }
    input::-webkit-slider-thumb{
      -webkit-appearance:none;
      appearance:none;
      width:18px;height:18px;border-radius:50%;
      background:var(--accent);
      border:none;
      box-shadow:0 2px 6px rgba(0,0,0,.2);
      cursor:pointer;
    }
    input{
      width:100%;
      padding:6px 8px;
      border:1px solid #dde4ea;
      border-radius:8px;
      outline:none;
    }
    input:focus{
      border-color:#86b7fe;
      box-shadow:0 0 0 3px rgba(13,110,253,.15);
    }

    .options{
      border:1px dashed #d7dee6;
      border-radius:10px;
      padding:10px;
      display:grid;
      gap:8px;
      background:#fafcff;
    }
    .options label{
      display:flex;
      align-items:center;
      gap:10px;
      font-weight:600;
    }

    .info{
      border-radius:10px;
      background:#f7fafc;
      padding:10px;
      color:#334;
      line-height:1.6;
    }
    .note{
      color:#a23b2a;
      font-weight:600;
    }

    .buttons{
      display:flex;
      gap:10px;
    }
    button{
      padding:8px 12px;
      border-radius:10px;
      border:1px solid #d6dde4;
      background:#fff;
      cursor:pointer;
    }
    button.primary{
      background:linear-gradient(180deg, #ffb26b, #ff7a18);
      color:#fff;border:none;
    }
    button:hover{filter:brightness(.98)}

    /* 移动端布局:上下排列 */
    @media (max-width: 980px){
      .app{flex-direction:column}
      .panel{max-height:none}
      .canvas-card{max-width:100%}
    }
</style>
</head>
<body>
<div class="app">
    <main class="canvas-col">
      <div class="canvas-card">
      <div class="canvas-wrap">
          <canvas id="graph" aria-label="二次函数图像画布"></canvas>
      </div>
      </div>
    </main>

    <aside class="panel">
      <div class="title">
      <div class="formula">y = a·x² + b·x + c</div>
      <div class="live">
          <span class="code">
            y = <b id="aLabel">1.00</b>·x² + <b id="bLabel">0.00</b>·x + <b id="cLabel">0.00</b>
          </span>
      </div>
      </div>

      <div class="controls">
      <div class="row">
          <label class="key" for="a">a</label>
          <input id="a" type="range" min="-10" max="10" step="0.1" value="1">
          <input id="aNum" type="number" min="-10" max="10" step="0.1" value="1">
      </div>
      <div class="row">
          <label class="key" for="b">b</label>
          <input id="b" type="range" min="-10" max="10" step="0.1" value="0">
          <input id="bNum" type="number" min="-10" max="10" step="0.1" value="0">
      </div>
      <div class="row">
          <label class="key" for="c">c</label>
          <input id="c" type="range" min="-10" max="10" step="0.1" value="0">
          <input id="cNum" type="number" min="-10" max="10" step="0.1" value="0">
      </div>

      <div class="row wide">
          <label class="key" for="xRange">范围 |x| ≤ <span id="rangeLabel">10</span></label>
          <input id="xRange" type="range" min="5" max="20" step="1" value="10">
          <input id="xRangeNum" type="number" min="5" max="20" step="1" value="10">
      </div>
      </div>

      <div class="options">
      <label><input type="checkbox" id="showVertex"> 顶点</label>
      <label><input type="checkbox" id="showAxis"> 对称轴</label>
      <label><input type="checkbox" id="showRoots"> 根</label>
      <label><input type="checkbox" id="showGrid" checked> 坐标(网格与刻度)</label>
      </div>

      <div class="info">
      <div>顶点 V = (<span id="vx">—</span>, <span id="vy">—</span>)</div>
      <div>对称轴 x = <span id="axis">—</span></div>
      <div>根:<span id="roots">—</span></div>
      <div class="note" id="note"></div>
      </div>

      <div class="buttons">
      <button id="reset">重置</button>
      <button id="save" class="primary">下载 PNG</button>
      </div>
    </aside>
</div>

<script>
(() => {
    // 元素引用
    const canvas = document.getElementById('graph');
    const ctx = canvas.getContext('2d');

    const aRange = document.getElementById('a');
    const bRange = document.getElementById('b');
    const cRange = document.getElementById('c');
    const aNum = document.getElementById('aNum');
    const bNum = document.getElementById('bNum');
    const cNum = document.getElementById('cNum');

    const aLabel = document.getElementById('aLabel');
    const bLabel = document.getElementById('bLabel');
    const cLabel = document.getElementById('cLabel');

    const xRange = document.getElementById('xRange');
    const xRangeNum = document.getElementById('xRangeNum');
    const rangeLabel = document.getElementById('rangeLabel');

    const chkVertex = document.getElementById('showVertex');
    const chkAxis = document.getElementById('showAxis');
    const chkRoots = document.getElementById('showRoots');
    const chkGrid = document.getElementById('showGrid');

    const vxEl = document.getElementById('vx');
    const vyEl = document.getElementById('vy');
    const axisEl = document.getElementById('axis');
    const rootsEl = document.getElementById('roots');
    const noteEl = document.getElementById('note');

    const btnReset = document.getElementById('reset');
    const btnSave = document.getElementById('save');

    // 状态
    const state = {
      a: 1, b: 0, c: 0,
      xMax: 10,
      showVertex: false,
      showAxis: false,
      showRoots: false,
      showGrid: true,
    };

    // 工具函数(两位小数,避免 -0.00)
    const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
    const fmt = (v) => {
      if (!isFinite(v)) return '—';
      const nearZero = Math.abs(v) < 5e-3 ? 0 : v; // 小于 0.005 视为 0
      return Number(nearZero).toFixed(2);
    };

    function updateLabels(){
      aLabel.textContent = fmt(state.a);
      bLabel.textContent = fmt(state.b);
      cLabel.textContent = fmt(state.c);

      rangeLabel.textContent = state.xMax;

      // 顶点/对称轴/根 数值
      const info = computeInfo();
      if (info.isQuadratic){
      vxEl.textContent = fmt(info.vx);
      vyEl.textContent = fmt(info.vy);
      axisEl.textContent = fmt(info.vx);
      if (info.roots.length === 0) {
          rootsEl.textContent = '无实根';
      } else if (info.roots.length === 1) {
          rootsEl.textContent = `x = ${fmt(info.roots)}`;
      } else {
          rootsEl.textContent = `x₁ = ${fmt(info.roots)}, x₂ = ${fmt(info.roots)}`;
      }
      noteEl.textContent = '';
      } else {
      // a = 0 时为一次函数或常数
      vxEl.textContent = '—';
      vyEl.textContent = '—';
      axisEl.textContent = '—';
      if (Math.abs(state.b) > 1e-9){
          rootsEl.textContent = `线性:x = ${fmt(-state.c/state.b)}`;
      } else {
          rootsEl.textContent = (Math.abs(state.c) < 1e-9) ? '恒等式(任意 x)' : '无解';
      }
      noteEl.textContent = '注意:a = 0 时,y = bx + c(不是二次函数)';
      }
    }

    function syncInputsFromState(){
      // 滑块和数字框同步
      aRange.value = state.a; aNum.value = state.a;
      bRange.value = state.b; bNum.value = state.b;
      cRange.value = state.c; cNum.value = state.c;

      xRange.value = state.xMax; xRangeNum.value = state.xMax;

      chkVertex.checked = state.showVertex;
      chkAxis.checked = state.showAxis;
      chkRoots.checked = state.showRoots;
      chkGrid.checked = state.showGrid;

      updateLabels();
    }

    // 计算顶点/根等
    function computeInfo(){
      const {a,b,c} = state;
      if (Math.abs(a) < 1e-12){
      let root = [];
      if (Math.abs(b) > 1e-12){
          root = [-c/b];
      } else {
          root = [];
      }
      return {isQuadratic:false, vx:NaN, vy:NaN, roots:root};
      }
      const vx = -b/(2*a);
      const vy = a*vx*vx + b*vx + c;
      const D = b*b - 4*a*c;
      let roots = [];
      if (D > 1e-12){
      const s = Math.sqrt(D);
      roots = [(-b - s)/(2*a), (-b + s)/(2*a)];
      roots.sort((m,n)=>m-n);
      } else if (Math.abs(D) <= 1e-12){
      roots = [-b/(2*a)];
      }
      return {isQuadratic:true, vx, vy, roots};
    }

    // 坐标系换算与绘图
    let deviceRatio = 1;
    let pxPerUnit = 1;
    let halfW = 0, halfH = 0;

    function resizeCanvas(){
      const rect = canvas.getBoundingClientRect();
      const dpr = Math.max(1, Math.floor(window.devicePixelRatio || 1));
      deviceRatio = dpr;

      canvas.width = Math.floor(rect.width * dpr);
      canvas.height = Math.floor(rect.height * dpr);

      ctx.setTransform(1,0,0,1,0,0);
      ctx.scale(dpr, dpr);

      halfW = rect.width / 2;
      halfH = rect.height / 2;

      pxPerUnit = rect.width / (2 * state.xMax);

      draw();
    }

    function toScreen(x, y){
      return {
      x: halfW + x * pxPerUnit,
      y: halfH - y * pxPerUnit
      };
    }

    function drawGrid(){
      if (!state.showGrid) return;

      ctx.save();
      ctx.lineWidth = 1;
      ctx.strokeStyle = '#eef3f7';

      const xMax = state.xMax;
      const yMax = halfH / pxPerUnit;

      // 细网格(1 单位)
      ctx.beginPath();
      for(let i = Math.ceil(-xMax); i <= Math.floor(xMax); i++){
      const p = toScreen(i, 0).x;
      ctx.moveTo(p, 0);
      ctx.lineTo(p, halfH*2);
      }
      for(let j = Math.ceil(-yMax); j <= Math.floor(yMax); j++){
      const p = toScreen(0, j).y;
      ctx.moveTo(0, p);
      ctx.lineTo(halfW*2, p);
      }
      ctx.stroke();

      // 坐标轴
      ctx.lineWidth = 1.5;
      ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--axes') || '#3b4856';

      // x 轴
      ctx.beginPath();
      ctx.moveTo(0, toScreen(0,0).y);
      ctx.lineTo(halfW*2, toScreen(0,0).y);
      ctx.stroke();

      // y 轴
      ctx.beginPath();
      ctx.moveTo(toScreen(0,0).x, 0);
      ctx.lineTo(toScreen(0,0).x, halfH*2);
      ctx.stroke();

      // 刻度与数字标记(每 1 单位)
      ctx.fillStyle = '#445';
      ctx.font = '12px system-ui, -apple-system, Segoe UI, Roboto, "PingFang SC", "Microsoft YaHei"';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'top';

      // x 轴刻度
      for(let i = Math.ceil(-xMax); i <= Math.floor(xMax); i++){
      const sx = toScreen(i, 0).x;
      const sy = toScreen(0,0).y;
      ctx.beginPath();
      ctx.moveTo(sx, sy - 4);
      ctx.lineTo(sx, sy + 4);
      ctx.strokeStyle = '#8b98a8';
      ctx.lineWidth = 1;
      ctx.stroke();
      if (i !== 0){
          ctx.fillText(i.toString(), sx, sy + 6);
      }
      }

      // y 轴刻度
      ctx.textAlign = 'right';
      ctx.textBaseline = 'middle';
      for(let j = Math.ceil(-yMax); j <= Math.floor(yMax); j++){
      const sy = toScreen(0, j).y;
      const sx = toScreen(0,0).x;
      ctx.beginPath();
      ctx.moveTo(sx - 4, sy);
      ctx.lineTo(sx + 4, sy);
      ctx.strokeStyle = '#8b98a8';
      ctx.lineWidth = 1;
      ctx.stroke();
      if (j !== 0){
          ctx.fillText(j.toString(), sx - 6, sy);
      }
      }

      // 轴箭头与标签
      ctx.fillStyle = '#222';
      // x 箭头
      {
      const y0 = toScreen(0,0).y;
      const xR = halfW*2 - 8;
      ctx.beginPath();
      ctx.moveTo(xR, y0);
      ctx.lineTo(xR-8, y0-5);
      ctx.lineTo(xR-8, y0+5);
      ctx.closePath();
      ctx.fill();
      ctx.fillText('x', xR-14, y0+10);
      }
      // y 箭头
      {
      const x0 = toScreen(0,0).x;
      const yT = 8;
      ctx.beginPath();
      ctx.moveTo(x0, yT);
      ctx.lineTo(x0-5, yT+8);
      ctx.lineTo(x0+5, yT+8);
      ctx.closePath();
      ctx.fill();
      ctx.fillText('y', x0+10, yT+4);
      }

      ctx.restore();
    }

    function drawParabola(){
      const {a,b,c} = state;

      ctx.save();
      ctx.lineWidth = 3;
      ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--accent') || '#ff7a18';
      ctx.beginPath();

      const xMax = state.xMax;
      const stepWorld = Math.max(1 / pxPerUnit, 0.01); // 约每像素采样
      let first = true;

      for(let x = -xMax; x <= xMax; x += stepWorld){
      const y = a*x*x + b*x + c;
      const p = toScreen(x, y);
      if (first){
          ctx.moveTo(p.x, p.y);
          first = false;
      } else {
          ctx.lineTo(p.x, p.y);
      }
      }
      // 最右端确保落笔
      {
      const y = a*xMax*xMax + b*xMax + c;
      const p = toScreen(xMax, y);
      ctx.lineTo(p.x, p.y);
      }
      ctx.stroke();
      ctx.restore();
    }

    function drawVertexAndAxis(){
      const info = computeInfo();
      if (!info.isQuadratic) return;

      // 对称轴
      if (state.showAxis){
      const vx = info.vx;
      const top = toScreen(vx,99999).y;
      const bot = toScreen(vx, -99999).y;

      ctx.save();
      ctx.setLineDash();
      ctx.lineDashOffset = 0;
      ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--axisAccent') || '#b046ff';
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(toScreen(vx,0).x, top);
      ctx.lineTo(toScreen(vx,0).x, bot);
      ctx.stroke();
      ctx.restore();
      }

      // 顶点
      if (state.showVertex){
      const p = toScreen(info.vx, info.vy);
      ctx.save();
      ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--vertex') || '#7a2fff';
      ctx.strokeStyle = '#fff';
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.arc(p.x, p.y, 5, 0, Math.PI*2);
      ctx.fill();
      ctx.stroke();
      // 标签(两位小数)
      ctx.font = '12px system-ui';
      ctx.fillStyle = '#222';
      ctx.textAlign = 'left';
      ctx.textBaseline = 'bottom';
      ctx.fillText(`V(${fmt(info.vx)}, ${fmt(info.vy)})`, p.x + 8, p.y - 6);
      ctx.restore();
      }
    }

    function drawRoots(){
      if (!state.showRoots) return;
      const info = computeInfo();
      const {a,b,c} = state;

      ctx.save();
      ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--root') || '#1f7ae0';
      ctx.strokeStyle = '#fff';
      ctx.lineWidth = 2;

      if (info.isQuadratic){
      if (info.roots.length > 0){
          for(const r of info.roots){
            const p = toScreen(r, 0);
            ctx.beginPath();
            ctx.arc(p.x, p.y, 5, 0, Math.PI*2);
            ctx.fill(); ctx.stroke();
          }
      }
      } else {
      // a=0 的一次函数根(若存在)
      if (Math.abs(b) > 1e-9){
          const r = -c/b;
          const p = toScreen(r, 0);
          ctx.beginPath();
          ctx.arc(p.x, p.y, 5, 0, Math.PI*2);
          ctx.fill(); ctx.stroke();
      }
      }
      ctx.restore();
    }

    function clear(){
      const rect = canvas.getBoundingClientRect();
      ctx.clearRect(0,0,rect.width,rect.height);
      // 白底(避免导出透明)
      ctx.save();
      ctx.fillStyle = '#ffffff';
      ctx.fillRect(0,0,rect.width,rect.height);
      ctx.restore();
    }

    function draw(){
      clear();
      drawGrid();
      drawParabola();
      drawVertexAndAxis();
      drawRoots();
      updateLabels();
    }

    // 事件绑定
    function bindTwoWay(rangeEl, numEl, onChange){
      const apply = (v) => {
      if (numEl.min !== '') v = clamp(+v, +numEl.min, +numEl.max);
      numEl.value = v;
      rangeEl.value = v;
      onChange(+v);
      };
      rangeEl.addEventListener('input', e => apply(e.target.value));
      numEl.addEventListener('input', e => apply(e.target.value));
      numEl.addEventListener('change', e => apply(e.target.value));
    }

    bindTwoWay(aRange, aNum, v => { state.a = +v; draw(); });
    bindTwoWay(bRange, bNum, v => { state.b = +v; draw(); });
    bindTwoWay(cRange, cNum, v => { state.c = +v; draw(); });

    bindTwoWay(xRange, xRangeNum, v => {
      state.xMax = Math.round(+v);
      resizeCanvas();
      syncInputsFromState();
      draw();
    });

    // 选项
    chkVertex.addEventListener('change', e => { state.showVertex = e.target.checked; draw(); });
    chkAxis.addEventListener('change', e => { state.showAxis = e.target.checked; draw(); });
    chkRoots.addEventListener('change', e => { state.showRoots = e.target.checked; draw(); });
    chkGrid.addEventListener('change', e => { state.showGrid = e.target.checked; draw(); });

    // 重置
    btnReset.addEventListener('click', () => {
      state.a = 1; state.b = 0; state.c = 0;
      state.xMax = 10;
      state.showVertex = false;
      state.showAxis = false;
      state.showRoots = false;
      state.showGrid = true;
      syncInputsFromState();
      resizeCanvas();
    });

    // 保存 PNG
    btnSave.addEventListener('click', () => {
      draw();
      const link = document.createElement('a');
      link.download = `quadratic_a${fmt(state.a)}_b${fmt(state.b)}_c${fmt(state.c)}.png`;
      link.href = canvas.toDataURL('image/png');
      link.click();
    });

    // 自适应
    window.addEventListener('resize', resizeCanvas, {passive:true});
    // 初始化
    syncInputsFromState();
    resizeCanvas();
})();
</script>
</body>
</html>



qiuhan1987 发表于 2025-8-14 20:59:15

收藏一下

Ming-02 发表于 2025-8-14 20:59:42

学到了

SQ551 发表于 2025-8-14 20:59:57

这个是不是可以给中学生上课用了

一只菜狗 发表于 2025-8-14 21:00:08

学习了

bohu 发表于 2025-8-14 21:00:09

很好,寡人又学到知识了{:10_256:}

skhzhuanqian 发表于 2025-8-14 21:00:43

向大佬学习{:10_327:}

clollipops 发表于 2025-8-14 21:00:44

学到了

PhilMao 发表于 2025-8-14 21:01:44

给娃搞一个

Jenny_ 发表于 2025-8-14 21:01:50

这是不是能给初中生可视化讲几何题了:)

DX3906-zxy 发表于 2025-8-14 21:02:02


这个完全可以用于中学生上课用,楼主厉害了

winnieGarden 发表于 2025-8-14 21:02:39

哇!码住

想个好名字@ 发表于 2025-8-14 21:04:44

太厉害了

kumu27 发表于 2025-8-14 21:05:16

学到了

神荼Q 发表于 2025-8-14 21:10:45

这么多,

125800 发表于 2025-8-14 21:13:02

太厉害了吧,码住码住

13351890899 发表于 2025-8-14 21:13:03

学习了学习了

Pioneer. 发表于 2025-8-14 21:17:54

学习了

疯狗马德森 发表于 2025-8-14 21:18:38

我来打卡学习啦

yasi 发表于 2025-8-14 21:18:50

要是我们以前有这东西,数学能不好吗
页: [1] 2 3 4
查看完整版本: 二次函数可视化