鱼C论坛

 找回密码
 立即注册
查看: 56|回复: 2

[作品展示] 三角函数可视化

[复制链接]
发表于 3 天前 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能^_^

您需要 登录 才可以下载或查看,没有账号?立即注册

x
f177adeabb3abd1c7842751226db29df.png

作品介绍:

通过鼠标可以在圆中转动角度,在下图中的三角函数图像上显示该角度所在的位置

在左侧的小面板上显示坐标位置、j角度值以及三角函数值


2333.gif

  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="utf-8" />
  5. <meta name="viewport" content="width=device-width, initial-scale=1" />
  6. <title>三角函数 · 单位圆联动(多圈角度 · 内圈优先)</title>
  7. <style>
  8.   :root{
  9.     --bg:#f7ebc5;
  10.     --panel:#ffffff;
  11.     --ink:#222;
  12.     --grid:#ececec;
  13.     --axes:#333;
  14.     --cos:#1f6afe;
  15.     --sin:#1fbf6b;
  16.     --tan:#ff6a3d;
  17.     --point:#e03131;
  18.     --shadow:0 6px 18px rgba(0,0,0,.08);
  19.     --radius:12px;
  20.   }
  21.   *{box-sizing:border-box}
  22.   body{
  23.     margin:0;
  24.     background:var(--bg);
  25.     color:var(--ink);
  26.     font:14px/1.45 system-ui,-apple-system,Segoe UI,Roboto,"PingFang SC","Microsoft YaHei","Noto Sans",Arial,sans-serif;
  27.   }
  28.   .app{
  29.     min-height:100vh;
  30.     padding:14px;
  31.     display:grid;
  32.     grid-template-columns: 280px 1fr 240px;
  33.     grid-template-rows: auto 1fr;
  34.     gap:14px;
  35.   }
  36.   @media (max-width: 1080px){
  37.     .app{
  38.       grid-template-columns: 1fr;
  39.       grid-template-rows: auto auto auto;
  40.     }
  41.   }
  42.   .card{
  43.     background:var(--panel);
  44.     border-radius:var(--radius);
  45.     box-shadow:var(--shadow);
  46.     padding:12px;
  47.   }
  48.   .panel-left{grid-column:1;grid-row:1/span 2}
  49.   .center-top{grid-column:2;grid-row:1}
  50.   .center-bottom{grid-column:2;grid-row:2}
  51.   .panel-right{grid-column:3;grid-row:1/span 2}
  52.   @media (max-width:1080px){
  53.     .panel-left{grid-column:1;grid-row:auto}
  54.     .center-top{grid-column:1}
  55.     .center-bottom{grid-column:1}
  56.     .panel-right{grid-column:1}
  57.   }

  58.   h3{margin:6px 0 10px;font-size:16px}
  59.   .kv{display:grid;grid-template-columns: 88px 1fr; gap:6px 8px; align-items:center}
  60.   .mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}

  61.   .canvas-wrap{position:relative;width:100%;aspect-ratio:1/1;min-height:260px}
  62.   .plot-wrap{position:relative;width:100%;aspect-ratio: 5/2; min-height:240px}
  63.   canvas{position:absolute;inset:0;width:100%;height:100%;display:block;border-radius:8px;background:#fff}

  64.   .ctrls{display:grid;gap:10px}
  65.   .ctrls .row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
  66.   label{user-select:none}
  67.   input[type="range"]{width:100%}
  68.   button{padding:8px 12px;border-radius:10px;border:1px solid #d6dde4;background:#fff;cursor:pointer}
  69.   button.primary{background:linear-gradient(180deg,#ffd27d,#ff9f1a);color:#222;border:none}
  70. </style>
  71. </head>
  72. <body>
  73.   <div class="app">
  74.     <!-- 左侧数值面板 -->
  75.     <section class="card panel-left">
  76.       <h3>值</h3>
  77.       <div class="kv">
  78.         <div>角</div><div id="angleRead" class="mono">0.0°</div>
  79.         <div>(x, y)</div><div id="xyRead" class="mono">(1.000, 0.000)</div>
  80.         <div id="exprName" class="mono">cosθ =</div>
  81.         <div id="exprVal" class="mono">x / 1 = 1.000</div>
  82.       </div>
  83.       <hr/>
  84.       <div>
  85.         <label><input type="radio" name="unit" value="deg" checked> 度</label>
  86.         <label style="margin-left:12px"><input type="radio" name="unit" value="rad"> 弧度</label>
  87.       </div>
  88.     </section>

  89.     <!-- 中央单位圆(半径=1,坐标补全,视角拉远) -->
  90.     <section class="card center-top">
  91.       <div class="canvas-wrap">
  92.         <canvas id="circle" aria-label="单位圆画布"></canvas>
  93.       </div>
  94.     </section>

  95.     <!-- 下方函数曲线(坐标补全) -->
  96.     <section class="card center-bottom">
  97.       <div class="plot-wrap">
  98.         <canvas id="plot" aria-label="三角函数曲线画布"></canvas>
  99.       </div>
  100.     </section>

  101.     <!-- 右侧控制 -->
  102.     <aside class="card panel-right">
  103.       <h3>显示</h3>
  104.       <div class="ctrls">
  105.         <div>
  106.           <label><input type="radio" name="func" value="cos" checked> <span style="color:var(--cos)">cos</span></label>
  107.           <label style="margin-left:12px"><input type="radio" name="func" value="sin"> <span style="color:var(--sin)">sin</span></label>
  108.           <label style="margin-left:12px"><input type="radio" name="func" value="tan"> <span style="color:var(--tan)">tan</span></label>
  109.         </div>
  110.         <div class="row">
  111.           <label><input type="checkbox" id="annotations" checked> 标识</label>
  112.           <label><input type="checkbox" id="grid" checked> 网格</label>
  113.         </div>
  114.         <hr/>
  115.         <div class="row" style="align-items:center">
  116.           <label style="width:56px">角度</label>
  117.           <input type="range" id="thetaRange" min="-720" max="720" step="0.1" value="0" />
  118.           <span class="mono" id="thetaLabel">0°</span>
  119.         </div>
  120.         <div class="row">
  121.           <button id="reset">重置</button>
  122.           <button id="save" class="primary">下载 PNG</button>
  123.         </div>
  124.       </div>
  125.     </aside>
  126.   </div>

  127. <script>
  128. (() => {
  129.   // 视角:单位圆视图
  130.   const VIEW_RANGE = 2;
  131.   const GRID_STEP = 0.5;
  132.   const MARGIN = 18;

  133.   // 状态
  134.   const state = {
  135.     theta: 0,            // 角度(度),可达 ±720°
  136.     func: 'cos',
  137.     unit: 'deg',
  138.     showGrid: true,
  139.     showAnnotations: true
  140.   };

  141.   // DOM
  142.   const circle = document.getElementById('circle');
  143.   const plot = document.getElementById('plot');
  144.   const ctxC = circle.getContext('2d');
  145.   const ctxP = plot.getContext('2d');

  146.   const thetaRange = document.getElementById('thetaRange');
  147.   const thetaLabel = document.getElementById('thetaLabel');
  148.   const angleRead = document.getElementById('angleRead');
  149.   const xyRead = document.getElementById('xyRead');
  150.   const exprName = document.getElementById('exprName');
  151.   const exprVal = document.getElementById('exprVal');

  152.   const radiosFunc = [...document.querySelectorAll('input[name="func"]')];
  153.   const radiosUnit = [...document.querySelectorAll('input[name="unit"]')];
  154.   const chkGrid   = document.getElementById('grid');
  155.   const chkAnno   = document.getElementById('annotations');

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

  158.   // 工具
  159.   const deg2rad = d => d * Math.PI / 180;
  160.   const rad2deg = r => r * 180 / Math.PI;
  161.   const clamp = (v,min,max)=>Math.min(max,Math.max(min,v));
  162.   const fmt = n => {
  163.     if (!isFinite(n)) return '—';
  164.     const near0 = Math.abs(n) < 5e-4 ? 0 : n;
  165.     return near0.toFixed(3);
  166.   };
  167.   const getVar = name => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
  168.   const colorFor = f => f==='cos' ? getVar('--cos') : (f==='sin' ? getVar('--sin') : getVar('--tan'));

  169.   // 自适应画布
  170.   function fitCanvas(canvas, ctx){
  171.     const rect = canvas.getBoundingClientRect();
  172.     const dpr = Math.max(1, window.devicePixelRatio || 1);
  173.     canvas.width = Math.floor(rect.width * dpr);
  174.     canvas.height = Math.floor(rect.height * dpr);
  175.     ctx.setTransform(1,0,0,1,0,0);
  176.     ctx.scale(dpr, dpr);
  177.   }
  178.   function resizeAll(){
  179.     fitCanvas(circle, ctxC);
  180.     fitCanvas(plot, ctxP);
  181.     drawAll();
  182.   }
  183.   window.addEventListener('resize', resizeAll, {passive:true});

  184.   // 角度滑块
  185.   thetaRange.addEventListener('input', e=>{
  186.     state.theta = +e.target.value;
  187.     syncUI();
  188.     drawAll();
  189.   });

  190.   // 函数选择
  191.   radiosFunc.forEach(r=>r.addEventListener('change', e=>{
  192.     if(e.target.checked){ state.func = e.target.value; drawAll(); syncUI(); }
  193.   }));

  194.   // 单位选择
  195.   radiosUnit.forEach(r=>r.addEventListener('change', e=>{
  196.     if(e.target.checked){ state.unit = e.target.value; syncUI(); drawAll(); }
  197.   }));

  198.   // 选项
  199.   chkGrid.addEventListener('change', e=>{ state.showGrid = e.target.checked; drawAll(); });
  200.   chkAnno.addEventListener('change', e=>{ state.showAnnotations = e.target.checked; drawAll(); });

  201.   // 拖动单位圆上的红点(角度解包,支持连续累计到 ±720°)
  202.   (function enableCircleDrag(){
  203.     let dragging = false;
  204.     let lastRawDeg = 0; // 上一帧 atan2 的原始角(-180,180]

  205.     function rawDegFromEvent(ev){
  206.       const rect = circle.getBoundingClientRect();
  207.       const cx = rect.width/2, cy = rect.height/2;
  208.       const x = ev.clientX - (rect.left + cx);
  209.       const y = ev.clientY - (rect.top + cy);
  210.       return rad2deg(Math.atan2(-y, x)); // [-180,180]
  211.     }

  212.     function onMove(ev){
  213.       if (!dragging) return;
  214.       const raw = rawDegFromEvent(ev);
  215.       let delta = raw - lastRawDeg;
  216.       // 解包到最短角差(避免 180&#8596;-180 跳变)
  217.       if (delta > 180) delta -= 360;
  218.       else if (delta < -180) delta += 360;

  219.       state.theta = clamp(state.theta + delta, -720, 720);
  220.       lastRawDeg = raw;

  221.       thetaRange.value = state.theta;
  222.       syncUI();
  223.       drawAll();
  224.     }

  225.     circle.addEventListener('pointerdown', ev=>{
  226.       dragging = true;
  227.       circle.setPointerCapture(ev.pointerId);
  228.       lastRawDeg = rawDegFromEvent(ev); // 记录起始角,不立刻改 θ
  229.     });
  230.     circle.addEventListener('pointermove', onMove);
  231.     circle.addEventListener('pointerup',   ()=> dragging = false);
  232.     circle.addEventListener('pointercancel',()=> dragging = false);
  233.   })();

  234.   // 文本 UI
  235.   function syncUI(){
  236.     const deg = state.theta;
  237.     const rad = deg2rad(deg);
  238.     thetaLabel.textContent = `${deg.toFixed(1)}°`;
  239.     angleRead.textContent = state.unit==='deg' ? `${deg.toFixed(1)}°` : `${fmt(rad)} rad`;

  240.     const x = Math.cos(rad), y = Math.sin(rad);
  241.     xyRead.textContent = `(${fmt(x)}, ${fmt(y)})`;

  242.     let name = state.func + 'θ =';
  243.     let expr;
  244.     if (state.func === 'cos') expr = `x / 1 = ${fmt(x)}`;
  245.     else if (state.func === 'sin') expr = `y / 1 = ${fmt(y)}`;
  246.     else expr = (Math.abs(Math.cos(rad)) < 1e-6) ? '未定义' : `y / x = ${fmt(Math.tan(rad))}`;
  247.     exprName.textContent = name;
  248.     exprVal.textContent = expr;
  249.   }

  250.   // 绘制单位圆(半径=1,坐标补全 + 多圈角度弧)
  251.   function drawCircle(){
  252.     const rect = circle.getBoundingClientRect();
  253.     const w = rect.width, h = rect.height;
  254.     const cx = w/2, cy = h/2;

  255.     // 1 单位像素
  256.     const unitPx = (Math.min(w,h) - 2*MARGIN) / (2*VIEW_RANGE);
  257.     const sx = x => cx + x * unitPx;
  258.     const sy = y => cy - y * unitPx;

  259.     // 背景
  260.     ctxC.clearRect(0,0,w,h);
  261.     ctxC.fillStyle = '#ffffff';
  262.     ctxC.fillRect(0,0,w,h);

  263.     // 网格
  264.     if (state.showGrid){
  265.       ctxC.save();
  266.       ctxC.strokeStyle = '#f3f3f3';
  267.       ctxC.lineWidth = 1;
  268.       ctxC.beginPath();
  269.       for(let x = -VIEW_RANGE; x <= VIEW_RANGE + 1e-9; x += GRID_STEP){
  270.         const X = Math.round(sx(x)) + 0.5;
  271.         ctxC.moveTo(X, 0); ctxC.lineTo(X, h);
  272.       }
  273.       for(let y = -VIEW_RANGE; y <= VIEW_RANGE + 1e-9; y += GRID_STEP){
  274.         const Y = Math.round(sy(y)) + 0.5;
  275.         ctxC.moveTo(0, Y); ctxC.lineTo(w, Y);
  276.       }
  277.       ctxC.stroke();
  278.       ctxC.restore();
  279.     }

  280.     // 坐标轴 + 箭头
  281.     const axesColor = getVar('--axes') || '#333';
  282.     ctxC.save();
  283.     ctxC.strokeStyle = axesColor;
  284.     ctxC.lineWidth = 1.6;

  285.     const y0 = sy(0);
  286.     ctxC.beginPath(); ctxC.moveTo(MARGIN/2, y0); ctxC.lineTo(w - MARGIN/2, y0); ctxC.stroke();
  287.     const x0 = sx(0);
  288.     ctxC.beginPath(); ctxC.moveTo(x0, MARGIN/2); ctxC.lineTo(x0, h - MARGIN/2); ctxC.stroke();
  289.     drawArrow(ctxC, w - MARGIN/2, y0, 'right', axesColor);
  290.     drawArrow(ctxC, x0, MARGIN/2, 'up', axesColor);

  291.     // 刻度(主整数,次 0.5)
  292.     ctxC.strokeStyle = '#8b98a8';
  293.     ctxC.lineWidth = 1;
  294.     for (let v=-VIEW_RANGE; v<=VIEW_RANGE+1e-9; v+=GRID_STEP){
  295.       const X = sx(v), len = (Math.abs(v % 1) < 1e-9) ? 6 : 3;
  296.       ctxC.beginPath(); ctxC.moveTo(X, y0-len); ctxC.lineTo(X, y0+len); ctxC.stroke();
  297.     }
  298.     for (let v=-VIEW_RANGE; v<=VIEW_RANGE+1e-9; v+=GRID_STEP){
  299.       const Y = sy(v), len = (Math.abs(v % 1) < 1e-9) ? 6 : 3;
  300.       ctxC.beginPath(); ctxC.moveTo(x0-len, Y); ctxC.lineTo(x0+len, Y); ctxC.stroke();
  301.     }
  302.     // 标签
  303.     ctxC.fillStyle = '#445';
  304.     ctxC.font = '12px system-ui, -apple-system, Segoe UI, Roboto';
  305.     ctxC.textAlign = 'center'; ctxC.textBaseline = 'top';
  306.     for (let v=-VIEW_RANGE; v<=VIEW_RANGE; v++){
  307.       const X = sx(v); ctxC.fillText(String(v), X, y0 + 8);
  308.     }
  309.     ctxC.textAlign = 'right'; ctxC.textBaseline = 'middle';
  310.     for (let v=-VIEW_RANGE; v<=VIEW_RANGE; v++){
  311.       const Y = sy(v); ctxC.fillText(String(v), x0 - 8, Y);
  312.     }
  313.     ctxC.textAlign = 'left'; ctxC.textBaseline = 'top';
  314.     ctxC.fillText('0', x0 + 6, y0 + 6);
  315.     ctxC.textAlign = 'right'; ctxC.textBaseline = 'top'; ctxC.fillText('x', w - MARGIN/2 - 6, y0 + 6);
  316.     ctxC.textAlign = 'left'; ctxC.textBaseline = 'bottom'; ctxC.fillText('y', x0 + 6, MARGIN/2 + 12);
  317.     ctxC.restore();

  318.     // 单位圆(半径=1)
  319.     ctxC.beginPath();
  320.     ctxC.lineWidth = 2;
  321.     ctxC.strokeStyle = '#000';
  322.     ctxC.arc(sx(0), sy(0), 1 * unitPx, 0, Math.PI*2);
  323.     ctxC.stroke();

  324.     // 当前点
  325.     const rad = deg2rad(state.theta);
  326.     const px = sx(Math.cos(rad));
  327.     const py = sy(Math.sin(rad));

  328.     // 半径线
  329.     ctxC.beginPath();
  330.     ctxC.moveTo(sx(0), sy(0));
  331.     ctxC.lineTo(px, py);
  332.     ctxC.strokeStyle = '#2b60ff';
  333.     ctxC.lineWidth = 2;
  334.     ctxC.stroke();

  335.     // 角度弧:第一圈在内、第二圈向外扩展
  336.     if (state.showAnnotations){
  337.       drawAngleRings(ctxC, sx(0), sy(0), unitPx, rad);
  338.     }

  339.     // 投影线
  340.     if (state.showAnnotations){
  341.       ctxC.save();
  342.       ctxC.setLineDash([6,4]);
  343.       ctxC.strokeStyle = '#000';
  344.       ctxC.lineWidth = 1.2;
  345.       ctxC.beginPath(); ctxC.moveTo(px, py); ctxC.lineTo(px, sy(0)); ctxC.stroke();
  346.       ctxC.beginPath(); ctxC.moveTo(px, py); ctxC.lineTo(sx(0), py); ctxC.stroke();
  347.       ctxC.restore();
  348.     }

  349.     // 红点
  350.     ctxC.beginPath();
  351.     ctxC.arc(px, py, 5, 0, Math.PI*2);
  352.     ctxC.fillStyle = getVar('--point') || '#e03131';
  353.     ctxC.strokeStyle = '#fff';
  354.     ctxC.lineWidth = 2;
  355.     ctxC.fill(); ctxC.stroke();
  356.   }

  357.   // 多圈角度弧(第一圈=内圈,第二圈=外圈)
  358.   function drawAngleRings(ctx, cx, cy, unitPx, thetaRad){
  359.     const dir = thetaRad >= 0 ? 1 : -1;
  360.     const A = Math.abs(thetaRad);
  361.     const fullTurns = Math.floor(A / (2*Math.PI)); // 已完成的整圈数:0,1,2
  362.     const rem = A - fullTurns * (2*Math.PI);       // 当前圈的剩余角度
  363.     const baseR = unitPx * 0.45; // 内圈半径(更靠近圆心、更小)
  364.     const gap   = unitPx * 0.14; // 圈间距(向外渐增)

  365.     ctx.save();
  366.     ctx.strokeStyle = '#2b60ff';
  367.     ctx.lineWidth = 2;
  368.     ctx.lineCap = 'round';

  369.     // 1) 先把已经完成的整圈,从内往外画满圈(无箭头)
  370.     for (let i = 0; i < fullTurns; i++){
  371.       const r = Math.max(6, baseR + i*gap);
  372.       ctx.beginPath();
  373.       ctx.arc(cx, cy, r, 0, Math.PI*2, dir < 0);
  374.       ctx.stroke();
  375.     }

  376.     // 2) 当前未完成的圈,画在“下一圈”(外侧)上,并加箭头
  377.     if (rem > 1e-6){
  378.       const rRem = Math.max(6, baseR + fullTurns*gap);
  379.       drawArcSegmentWithArrow(ctx, cx, cy, rRem, dir * rem);
  380.     }

  381.     ctx.restore();
  382.   }

  383.   // 从 0 到 theta 的弧线 + 箭头(theta 可正可负)
  384.   function drawArcSegmentWithArrow(ctx, cx, cy, r, theta){
  385.     const steps = Math.max(12, Math.ceil(Math.abs(theta) / (Math.PI / 48)));
  386.     let xPrev = cx + r, yPrev = cy; // 起点:+x 方向
  387.     let xCurr = xPrev, yCurr = yPrev;

  388.     ctx.save();
  389.     ctx.strokeStyle = '#2b60ff';
  390.     ctx.lineWidth = 2;

  391.     ctx.beginPath();
  392.     ctx.moveTo(xPrev, yPrev);
  393.     for (let i=1;i<=steps;i++){
  394.       const t = theta * (i/steps);
  395.       const x = cx + r * Math.cos(t);
  396.       const y = cy - r * Math.sin(t);
  397.       xPrev = xCurr; yPrev = yCurr;
  398.       xCurr = x;     yCurr = y;
  399.       ctx.lineTo(x, y);
  400.     }
  401.     ctx.stroke();

  402.     // 箭头
  403.     const vx = xCurr - xPrev;
  404.     const vy = yCurr - yPrev;
  405.     const len = Math.hypot(vx, vy) || 1;
  406.     const ux = vx/len, uy = vy/len;
  407.     const ah = 8, aw = 6;
  408.     ctx.fillStyle = '#2b60ff';
  409.     ctx.beginPath();
  410.     ctx.moveTo(xCurr, yCurr);
  411.     ctx.lineTo(xCurr - ux*ah + (-uy)*aw, yCurr - uy*ah + (ux)*aw);
  412.     ctx.lineTo(xCurr - ux*ah - (-uy)*aw, yCurr - uy*ah - (ux)*aw);
  413.     ctx.closePath();
  414.     ctx.fill();

  415.     ctx.restore();
  416.   }

  417.   // 绘制三角函数曲线(坐标补全)
  418.   function drawPlot(){
  419.     const rect = plot.getBoundingClientRect();
  420.     const w = rect.width, h = rect.height;
  421.     const pad = 18;
  422.     const left = pad+4, right = w-pad-4, top = pad, bottom = h-pad;
  423.     const innerW = right-left, innerH = bottom-top;

  424.     ctxP.clearRect(0,0,w,h);
  425.     ctxP.fillStyle = '#fff';
  426.     ctxP.fillRect(0,0,w,h);

  427.     // x 范围:以当前 θ 为中心 ±2π
  428.     const theta0 = deg2rad(state.theta);
  429.     const xMin = theta0 - 2*Math.PI;
  430.     const xMax = theta0 + 2*Math.PI;

  431.     // y 范围
  432.     const yMax = (state.func==='tan') ? 3 : 1.2;
  433.     const yMin = -yMax;

  434.     // 映射
  435.     const sx = t => left + (t - xMin) / (xMax - xMin) * innerW;
  436.     const sy = y => bottom - (y - yMin) / (yMax - yMin) * innerH;

  437.     // 网格
  438.     if (state.showGrid){
  439.       ctxP.save();
  440.       ctxP.strokeStyle = getVar('--grid') || '#ececec';
  441.       ctxP.lineWidth = 1;

  442.       // 竖向网格:每 π/6
  443.       const stepMinor = Math.PI/6;
  444.       const sMin = Math.ceil(xMin/stepMinor);
  445.       const sMax = Math.floor(xMax/stepMinor);
  446.       ctxP.beginPath();
  447.       for(let k=sMin; k<=sMax; k++){
  448.         const x = sx(k*stepMinor);
  449.         ctxP.moveTo(x, top); ctxP.lineTo(x, bottom);
  450.       }
  451.       ctxP.stroke();

  452.       // 横向网格
  453.       ctxP.beginPath();
  454.       if (state.func==='tan'){
  455.         for (let v=-3; v<=3; v++){
  456.           const y = sy(v);
  457.           ctxP.moveTo(left, y); ctxP.lineTo(right, y);
  458.         }
  459.       }else{
  460.         const ys = [-1,-0.5,0,0.5,1];
  461.         ys.forEach(v=>{ const y=sy(v); ctxP.moveTo(left,y); ctxP.lineTo(right,y); });
  462.       }
  463.       ctxP.stroke();
  464.       ctxP.restore();
  465.     }

  466.     // 坐标轴 + 箭头
  467.     const axesColor = getVar('--axes') || '#333';
  468.     ctxP.save();
  469.     ctxP.strokeStyle = axesColor;
  470.     ctxP.lineWidth = 1.6;

  471.     const y0 = sy(0);
  472.     ctxP.beginPath(); ctxP.moveTo(left, y0); ctxP.lineTo(right, y0); ctxP.stroke();
  473.     drawArrow(ctxP, right, y0, 'right', axesColor);

  474.     if (xMin < 0 && xMax > 0){
  475.       const x0 = sx(0);
  476.       ctxP.beginPath(); ctxP.moveTo(x0, top); ctxP.lineTo(x0, bottom); ctxP.stroke();
  477.       drawArrow(ctxP, x0, top, 'up', axesColor);
  478.     }
  479.     ctxP.restore();

  480.     // 刻度与标签(x 主:π/2;次:π/6)
  481.     ctxP.save();
  482.     ctxP.fillStyle = '#445';
  483.     ctxP.strokeStyle = '#8b98a8';
  484.     ctxP.font = '12px system-ui, -apple-system, Segoe UI, Roboto';

  485.     const stepMajor = Math.PI/2, stepMinor = Math.PI/6;
  486.     const kMinM = Math.ceil(xMin/stepMinor), kMaxM = Math.floor(xMax/stepMinor);
  487.     for(let k=kMinM; k<=kMaxM; k++){
  488.       const X = sx(k*stepMinor);
  489.       const isMajor = (k % 3 === 0);
  490.       const len = isMajor ? 7 : 4;
  491.       ctxP.beginPath(); ctxP.moveTo(X, y0-len); ctxP.lineTo(X, y0+len); ctxP.stroke();
  492.     }
  493.     // 主刻度标签(π/2 倍数)
  494.     const kMin = Math.ceil(xMin/stepMajor), kMax = Math.floor(xMax/stepMajor);
  495.     ctxP.textAlign = 'center'; ctxP.textBaseline = 'top';
  496.     for(let k=kMin; k<=kMax; k++){
  497.       ctxP.fillText(piLabelFromK(k), sx(k*stepMajor), y0 + 8);
  498.     }
  499.     ctxP.textAlign = 'right'; ctxP.textBaseline = 'top';
  500.     ctxP.fillText('θ', right - 6, y0 + 8);

  501.     // y 刻度与标签
  502.     const yTicks = (state.func==='tan') ? [-3,-2,-1,0,1,2,3] : [-1,-0.5,0,0.5,1];
  503.     const xForTicks = (xMin < 0 && xMax > 0) ? sx(0) : left;
  504.     ctxP.textAlign = 'right'; ctxP.textBaseline = 'middle';
  505.     yTicks.forEach(v=>{
  506.       const Y = sy(v);
  507.       const len = (Math.abs(v % 1) < 1e-9) ? 7 : 4;
  508.       ctxP.beginPath(); ctxP.moveTo(xForTicks-len, Y); ctxP.lineTo(xForTicks+len, Y); ctxP.stroke();
  509.       ctxP.fillText(String(v), xForTicks - 8, Y);
  510.     });
  511.     ctxP.textAlign = 'left'; ctxP.textBaseline = 'bottom';
  512.     const yName = state.func + 'θ';
  513.     const xAxisForName = (xMin < 0 && xMax > 0) ? sx(0) + 6 : left + 6;
  514.     ctxP.fillText(yName, xAxisForName, top + 14);
  515.     ctxP.restore();

  516.     // tan 渐近线
  517.     if (state.func==='tan'){
  518.       ctxP.save();
  519.       ctxP.setLineDash([6,6]);
  520.       ctxP.strokeStyle = '#f1a47f';
  521.       ctxP.lineWidth = 1.2;
  522.       const step = Math.PI;
  523.       const start = Math.ceil((xMin - Math.PI/2)/step)*step + Math.PI/2;
  524.       for (let x = start; x <= xMax; x += step){
  525.         const X = sx(x);
  526.         ctxP.beginPath(); ctxP.moveTo(X, top); ctxP.lineTo(X, bottom); ctxP.stroke();
  527.       }
  528.       ctxP.restore();
  529.     }

  530.     // 曲线
  531.     ctxP.save();
  532.     ctxP.strokeStyle = colorFor(state.func);
  533.     ctxP.lineWidth = 2;

  534.     const fn = (t)=>{
  535.       if (state.func==='cos') return Math.cos(t);
  536.       if (state.func==='sin') return Math.sin(t);
  537.       const c = Math.cos(t);
  538.       if (Math.abs(c) < 1e-6) return Infinity;
  539.       return Math.tan(t);
  540.     };

  541.     const samples = Math.max(240, Math.floor(innerW));
  542.     let first = true;
  543.     let lastY = null;
  544.     ctxP.beginPath();
  545.     for(let i=0;i<=samples;i++){
  546.       const t = xMin + (i/samples)*(xMax-xMin);
  547.       let y = fn(t);
  548.       if (state.func==='tan'){
  549.         if (!isFinite(y) || Math.abs(Math.cos(t))<0.015){ lastY=null; first=true; continue; }
  550.         y = Math.max(-3, Math.min(3, y));
  551.       }
  552.       const X = sx(t), Y = sy(y);
  553.       if (first){ ctxP.moveTo(X, Y); first = false; }
  554.       else{
  555.         if (state.func==='tan' && lastY !== null && Math.abs(Y - lastY) > innerH*0.5){
  556.           ctxP.moveTo(X, Y);
  557.         }else ctxP.lineTo(X, Y);
  558.       }
  559.       lastY = Y;
  560.     }
  561.     ctxP.stroke();
  562.     ctxP.restore();

  563.     // 当前 θ 的标记
  564.     const fx = fn(theta0);
  565.     const X0 = sx(theta0);
  566.     if (state.showAnnotations){
  567.       ctxP.save();
  568.       ctxP.setLineDash([6,4]);
  569.       ctxP.strokeStyle = '#999';
  570.       ctxP.beginPath(); ctxP.moveTo(X0, top); ctxP.lineTo(X0, bottom); ctxP.stroke();
  571.       ctxP.restore();
  572.     }
  573.     if (isFinite(fx) && !(state.func==='tan' && Math.abs(Math.cos(theta0))<1e-6)){
  574.       const Y0 = sy(Math.max(-3, Math.min(3, fx)));
  575.       ctxP.beginPath();
  576.       ctxP.arc(X0, Y0, 5, 0, Math.PI*2);
  577.       ctxP.fillStyle = getVar('--point');
  578.       ctxP.strokeStyle = '#fff';
  579.       ctxP.lineWidth = 2;
  580.       ctxP.fill(); ctxP.stroke();
  581.     }
  582.   }

  583.   // π 标签:t = k*(π/2) 的文本
  584.   const gcd = (a,b)=> b===0 ? Math.abs(a) : gcd(b, a % b);
  585.   function piLabelFromK(k){
  586.     if (k === 0) return '0';
  587.     const g = gcd(Math.abs(k), 2);
  588.     const num = k / g;
  589.     const den = 2 / g;
  590.     const s = (num === 1) ? '' : (num === -1 ? '-' : String(num));
  591.     return s + 'π' + (den === 1 ? '' : '/' + den);
  592.   }

  593.   // 画箭头
  594.   function drawArrow(ctx, x, y, dir, color){
  595.     ctx.save();
  596.     ctx.fillStyle = color || '#333';
  597.     const L = 10, W = 6;
  598.     ctx.beginPath();
  599.     if (dir === 'right'){
  600.       ctx.moveTo(x, y);
  601.       ctx.lineTo(x - L, y - W/2);
  602.       ctx.lineTo(x - L, y + W/2);
  603.     }else if (dir === 'up'){
  604.       ctx.moveTo(x, y);
  605.       ctx.lineTo(x - W/2, y + L);
  606.       ctx.lineTo(x + W/2, y + L);
  607.     }
  608.     ctx.closePath();
  609.     ctx.fill();
  610.     ctx.restore();
  611.   }

  612.   function drawAll(){
  613.     drawCircle();
  614.     drawPlot();
  615.   }

  616.   // 重置
  617.   btnReset.addEventListener('click', ()=>{
  618.     state.theta = 0;
  619.     thetaRange.value = 0;
  620.     state.func = 'cos';
  621.     radiosFunc.forEach(r=> r.checked = (r.value==='cos'));
  622.     state.unit = 'deg';
  623.     radiosUnit.forEach(r=> r.checked = (r.value==='deg'));
  624.     state.showGrid = true;  chkGrid.checked = true;
  625.     state.showAnnotations = true; chkAnno.checked = true;
  626.     syncUI();
  627.     drawAll();
  628.   });

  629.   // 下载 PNG
  630.   btnSave.addEventListener('click', ()=>{
  631.     drawAll();

  632.     const cw = circle.getBoundingClientRect().width;
  633.     const ch = circle.getBoundingClientRect().height;
  634.     const pw = plot.getBoundingClientRect().width;
  635.     const ph = plot.getBoundingClientRect().height;

  636.     const W = Math.max(cw, pw);
  637.     const H = ch + 10 + ph;

  638.     const off = document.createElement('canvas');
  639.     off.width = W; off.height = H;
  640.     const g = off.getContext('2d');
  641.     g.fillStyle = '#fff'; g.fillRect(0,0,W,H);

  642.     const cImg = new Image();
  643.     const pImg = new Image();
  644.     const once = (img, src) => new Promise(res => { img.onload = res; img.src = src; });
  645.     Promise.all([
  646.       once(cImg, circle.toDataURL('image/png')),
  647.       once(pImg, plot.toDataURL('image/png'))
  648.     ]).then(()=>{
  649.       const cx = (W - cw)/2;
  650.       const px = (W - pw)/2;
  651.       g.drawImage(cImg, cx, 0, cw, ch);
  652.       g.drawImage(pImg, px, ch + 10, pw, ph);

  653.       const a = document.createElement('a');
  654.       a.download = `trig_${state.func}_theta${state.theta.toFixed(1)}.png`;
  655.       a.href = off.toDataURL('image/png');
  656.       a.click();
  657.     });
  658.   });

  659.   // 启动
  660.   resizeAll();
  661.   syncUI();
  662.   drawAll();
  663. })();
  664. </script>
  665. </body>
  666. </html>
复制代码



评分

参与人数 1荣誉 +5 鱼币 +5 贡献 +5 C币 +5 收起 理由
小甲鱼 + 5 + 5 + 5 + 5 鱼C有你更精彩^_^

查看全部评分

小甲鱼最新课程 -> https://ilovefishc.com
回复

使用道具 举报

发表于 3 天前 | 显示全部楼层
三角函数可视化工具说明


这是一个基于Canvas的三角函数可视化工具,支持通过鼠标交互查看单位圆与三角函数图像的关系。下面是完整实现代码:

  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4.     <meta charset="UTF-8">
  5.     <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6.     <title>三角函数可视化</title>
  7.     <style>
  8.         :root {
  9.             --bg: #f7ebc5;
  10.             --panel: #ffffff;
  11.             --ink: #222;
  12.             --grid: #ececec;
  13.             --axes: #333;
  14.             --cos: #1f6afe;
  15.             --sin: #1fbf6b;
  16.             --tan: #ff6a3d;
  17.             --point: #e03131;
  18.             --shadow: 0 6px 18px rgba(0,0,0,.08);
  19.             --radius: 12px;
  20.         }
  21.         * { box-sizing: border-box; }
  22.         body {
  23.             margin: 0;
  24.             background: var(--bg);
  25.             color: var(--ink);
  26.             font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, "PingFang SC", "Microsoft YaHei", "Noto Sans", Arial, sans-serif;
  27.         }
  28.         .app {
  29.             min-height: 100vh;
  30.             padding: 14px;
  31.             display: grid;
  32.             grid-template-columns: 280px 1fr 240px;
  33.             grid-template-rows: auto 1fr;
  34.             gap: 14px;
  35.         }
  36.         @media (max-width: 1080px) {
  37.             .app {
  38.                 grid-template-columns: 1fr;
  39.                 grid-template-rows: auto auto auto;
  40.             }
  41.         }
  42.         .card {
  43.             background: var(--panel);
  44.             border-radius: var(--radius);
  45.             box-shadow: var(--shadow);
  46.             padding: 12px;
  47.         }
  48.         .panel-left { grid-column: 1; grid-row: 1/span 2; }
  49.         .center-top { grid-column: 2; grid-row: 1; }
  50.         .center-bottom { grid-column: 2; grid-row: 2; }
  51.         .panel-right { grid-column: 3; grid-row: 1/span 2; }
  52.         @media (max-width:1080px) {
  53.             .panel-left { grid-column: 1; grid-row: auto; }
  54.             .center-top { grid-column: 1; }
  55.             .center-bottom { grid-column: 1; }
  56.             .panel-right { grid-column: 1; }
  57.         }

  58.         h3 { margin: 6px 0 10px; font-size: 16px; }
  59.         .kv { display: grid; grid-template-columns: 88px 1fr; gap: 6px 8px; align-items: center; }
  60.         .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }

  61.         .canvas-wrap { position: relative; width: 100%; aspect-ratio: 1/1; min-height: 260px; }
  62.         .plot-wrap { position: relative; width: 100%; aspect-ratio: 5/2; min-height: 240px; }
  63.         canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; border-radius: 8px; background: #fff; }

  64.         .ctrls { display: grid; gap: 10px; }
  65.         .ctrls .row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
  66.         label { user-select: none; }
  67.         input { width: 100%; }
  68.         button { padding: 8px 12px; border-radius: 10px; border: 1px solid #d6dde4; background: #fff; cursor: pointer; }
  69.         button.primary { background: linear-gradient(180deg, #ffd27d, #ff9f1a); color: #222; border: none; }
  70.     </style>
  71. </head>
  72. <body>
  73.     <div class="app">
  74.         <!-- 左侧信息面板 -->
  75.         <div class="card panel-left">
  76.             <h3>值</h3>
  77.             <div class="kv">
  78.                 <div>角</div>
  79.                 <div id="angleRead" class="mono">0.0°</div>
  80.                 <div>(x, y)</div>
  81.                 <div id="xyRead" class="mono">(1.000, 0.000)</div>
  82.                 <div id="exprName">cosθ =</div>
  83.                 <div id="exprVal" class="mono">x / 1 = 1.000</div>
  84.             </div>
  85.         </div>

  86.         <!-- 中间上方:单位圆 -->
  87.         <div class="card center-top">
  88.             <div class="canvas-wrap">
  89.                 <canvas id="circle"></canvas>
  90.             </div>
  91.         </div>

  92.         <!-- 中间下方:三角函数图像 -->
  93.         <div class="card center-bottom">
  94.             <div class="plot-wrap">
  95.                 <canvas id="plot"></canvas>
  96.             </div>
  97.         </div>

  98.         <!-- 右侧控制面板 -->
  99.         <div class="card panel-right">
  100.             <h3>显示</h3>
  101.             <div class="ctrls">
  102.                 <div class="row">
  103.                     <label><input type="radio" name="func" value="cos" checked> cos</label>
  104.                     <label><input type="radio" name="func" value="sin"> sin</label>
  105.                     <label><input type="radio" name="func" value="tan"> tan</label>
  106.                 </div>
  107.                 <div class="row">
  108.                     <label><input type="radio" name="unit" value="deg" checked> 度</label>
  109.                     <label><input type="radio" name="unit" value="rad"> 弧度</label>
  110.                 </div>
  111.                 <div class="row">
  112.                     <label><input type="checkbox" id="grid" checked> 网格</label>
  113.                     <label><input type="checkbox" id="annotations" checked> 标识</label>
  114.                 </div>
  115.                 <div class="row">
  116.                     <label for="thetaRange">角度</label>
  117.                     <span id="thetaLabel">0°</span>
  118.                 </div>
  119.                 <input type="range" id="thetaRange" min="-720" max="720" value="0" step="0.1">
  120.                 <div class="row">
  121.                     <button id="reset">重置</button>
  122.                     <button id="save" class="primary">下载 PNG</button>
  123.                 </div>
  124.             </div>
  125.         </div>
  126.     </div>

  127.     <script>
  128.         (() => {
  129.             // 视角:单位圆视图
  130.             const VIEW_RANGE = 2;
  131.             const GRID_STEP = 0.5;
  132.             const MARGIN = 18;

  133.             // 状态
  134.             const state = {
  135.                 theta: 0,            // 角度(度),可达 ±720°
  136.                 func: 'cos',
  137.                 unit: 'deg',
  138.                 showGrid: true,
  139.                 showAnnotations: true
  140.             };

  141.             // DOM
  142.             const circle = document.getElementById('circle');
  143.             const plot = document.getElementById('plot');
  144.             const ctxC = circle.getContext('2d');
  145.             const ctxP = plot.getContext('2d');

  146.             const thetaRange = document.getElementById('thetaRange');
  147.             const thetaLabel = document.getElementById('thetaLabel');
  148.             const angleRead = document.getElementById('angleRead');
  149.             const xyRead = document.getElementById('xyRead');
  150.             const exprName = document.getElementById('exprName');
  151.             const exprVal = document.getElementById('exprVal');

  152.             const radiosFunc = document.querySelectorAll('input[name="func"]');
  153.             const radiosUnit = document.querySelectorAll('input[name="unit"]');
  154.             const chkGrid   = document.getElementById('grid');
  155.             const chkAnno   = document.getElementById('annotations');

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

  158.             // 工具
  159.             const deg2rad = d => d * Math.PI / 180;
  160.             const rad2deg = r => r * 180 / Math.PI;
  161.             const clamp = (v,min,max)=>Math.min(max,Math.max(min,v));
  162.             const fmt = n => {
  163.                 if (!isFinite(n)) return '—';
  164.                 const near0 = Math.abs(n) < 5e-4 ? 0 : n;
  165.                 return near0.toFixed(3);
  166.             };
  167.             const getVar = name => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
  168.             const colorFor = f => f==='cos' ? getVar('--cos') : (f==='sin' ? getVar('--sin') : getVar('--tan'));

  169.             // 自适应画布
  170.             function fitCanvas(canvas, ctx){
  171.                 const rect = canvas.getBoundingClientRect();
  172.                 const dpr = Math.max(1, window.devicePixelRatio || 1);
  173.                 canvas.width = Math.floor(rect.width * dpr);
  174.                 canvas.height = Math.floor(rect.height * dpr);
  175.                 ctx.setTransform(1,0,0,1,0,0);
  176.                 ctx.scale(dpr, dpr);
  177.             }
  178.             function resizeAll(){
  179.                 fitCanvas(circle, ctxC);
  180.                 fitCanvas(plot, ctxP);
  181.                 drawAll();
  182.             }
  183.             window.addEventListener('resize', resizeAll, {passive:true});

  184.             // 角度滑块
  185.             thetaRange.addEventListener('input', e=>{
  186.                 state.theta = +e.target.value;
  187.                 syncUI();
  188.                 drawAll();
  189.             });

  190.             // 函数选择
  191.             radiosFunc.forEach(r=>r.addEventListener('change', e=>{
  192.                 if(e.target.checked){ state.func = e.target.value; drawAll(); syncUI(); }
  193.             }));

  194.             // 单位选择
  195.             radiosUnit.forEach(r=>r.addEventListener('change', e=>{
  196.                 if(e.target.checked){ state.unit = e.target.value; syncUI(); drawAll(); }
  197.             }));

  198.             // 选项
  199.             chkGrid.addEventListener('change', e=>{ state.showGrid = e.target.checked; drawAll(); });
  200.             chkAnno.addEventListener('change', e=>{ state.showAnnotations = e.target.checked; drawAll(); });

  201.             // 拖动单位圆上的红点(角度解包,支持连续累计到 ±720°)
  202.             (function enableCircleDrag(){
  203.                 let dragging = false;
  204.                 let lastRawDeg = 0; // 上一帧 atan2 的原始角(-180,180]

  205.                 function rawDegFromEvent(ev){
  206.                     const rect = circle.getBoundingClientRect();
  207.                     const cx = rect.width/2, cy = rect.height/2;
  208.                     const x = ev.clientX - (rect.left + cx);
  209.                     const y = ev.clientY - (rect.top + cy);
  210.                     return rad2deg(Math.atan2(-y, x));
  211.                 }

  212.                 function onMove(ev){
  213.                     if (!dragging) return;
  214.                     const raw = rawDegFromEvent(ev);
  215.                     let delta = raw - lastRawDeg;
  216.                     // 解包到最短角差(避免 180<->-180 跳变)
  217.                     if (delta > 180) delta -= 360;
  218.                     else if (delta < -180) delta += 360;

  219.                     state.theta = clamp(state.theta + delta, -720, 720);
  220.                     lastRawDeg = raw;

  221.                     thetaRange.value = state.theta;
  222.                     syncUI();
  223.                     drawAll();
  224.                 }

  225.                 circle.addEventListener('pointerdown', ev=>{
  226.                     dragging = true;
  227.                     circle.setPointerCapture(ev.pointerId);
  228.                     lastRawDeg = rawDegFromEvent(ev);
  229.                 });
  230.                 circle.addEventListener('pointermove', onMove);
  231.                 circle.addEventListener('pointerup',   ()=> dragging = false);
  232.                 circle.addEventListener('pointercancel',()=> dragging = false);
  233.             })();

  234.             // 文本 UI
  235.             function syncUI(){
  236.                 const deg = state.theta;
  237.                 const rad = deg2rad(deg);
  238.                 thetaLabel.textContent = `${deg.toFixed(1)}°`;
  239.                 angleRead.textContent = state.unit==='deg' ? `${deg.toFixed(1)}°` : `${fmt(rad)} rad`;

  240.                 const x = Math.cos(rad), y = Math.sin(rad);
  241.                 xyRead.textContent = `(${fmt(x)}, ${fmt(y)})`;

  242.                 let name = state.func + 'θ =';
  243.                 let expr;
  244.                 if (state.func === 'cos') expr = `x / 1 = ${fmt(x)}`;
  245.                 else if (state.func === 'sin') expr = `y / 1 = ${fmt(y)}`;
  246.                 else expr = (Math.abs(Math.cos(rad)) < 1e-6) ? '未定义' : `y / x = ${fmt(Math.tan(rad))}`;
  247.                 exprName.textContent = name;
  248.                 exprVal.textContent = expr;
  249.             }

  250.             // 绘制单位圆(半径=1,坐标补全 + 多圈角度弧)
  251.             function drawCircle(){
  252.                 const rect = circle.getBoundingClientRect();
  253.                 const w = rect.width, h = rect.height;
  254.                 const cx = w/2, cy = h/2;

  255.                 // 1 单位像素
  256.                 const unitPx = (Math.min(w,h) - 2*MARGIN) / (2*VIEW_RANGE);
  257.                 const sx = x => cx + x * unitPx;
  258.                 const sy = y => cy - y * unitPx;

  259.                 // 背景
  260.                 ctxC.clearRect(0,0,w,h);
  261.                 ctxC.fillStyle = '#ffffff';
  262.                 ctxC.fillRect(0,0,w,h);

  263.                 // 网格
  264.                 if (state.showGrid){
  265.                     ctxC.save();
  266.                     ctxC.strokeStyle = '#f3f3f3';
  267.                     ctxC.lineWidth = 1;
  268.                     ctxC.beginPath();
  269.                     for(let x = -VIEW_RANGE; x <= VIEW_RANGE; x += GRID_STEP) {
  270.                         ctxC.moveTo(sx(x), sy(-VIEW_RANGE));
  271.                         ctxC.lineTo(sx(x), sy(VIEW_RANGE));
  272.                     }
  273.                     for(let y = -VIEW_RANGE; y <= VIEW_RANGE; y += GRID_STEP) {
  274.                         ctxC.moveTo(sx(-VIEW_RANGE), sy(y));
  275.                         ctxC.lineTo(sx(VIEW_RANGE), sy(y));
  276.                     }
  277.                     ctxC.stroke();
  278.                     ctxC.restore();
  279.                 }

  280.                 // 坐标轴
  281.                 ctxC.save();
  282.                 ctxC.strokeStyle = getVar('--axes');
  283.                 ctxC.lineWidth = 2;
  284.                 ctxC.beginPath();
  285.                 ctxC.moveTo(sx(-VIEW_RANGE), sy(0));
  286.                 ctxC.lineTo(sx(VIEW_RANGE), sy(0));
  287.                 ctxC.moveTo(sx(0), sy(-VIEW_RANGE));
  288.                 ctxC.lineTo(sx(0), sy(VIEW_RANGE));
  289.                 ctxC.stroke();
  290.                 ctxC.restore();

  291.                 // 单位圆
  292.                 ctxC.save();
  293.                 ctxC.strokeStyle = '#999';
  294.                 ctxC.lineWidth = 2;
  295.                 ctxC.beginPath();
  296.                 ctxC.arc(cx, cy, unitPx, 0, Math.PI*2);
  297.                 ctxC.stroke();
  298.                 ctxC.restore();

  299.                 // 角度弧(多圈)
  300.                 const rad = deg2rad(state.theta);
  301.                 const turns = Math.floor(state.theta / 360);
  302.                 const remainder = state.theta % 360;
  303.                 const remainderRad = deg2rad(remainder);

  304.                 // 绘制多圈弧线
  305.                 if (turns !== 0) {
  306.                     ctxC.save();
  307.                     ctxC.strokeStyle = '#ff9f1a80';
  308.                     ctxC.lineWidth = 3;
  309.                     ctxC.beginPath();
  310.                     ctxC.arc(cx, cy, unitPx*0.8, 0, deg2rad(state.theta), state.theta < 0);
  311.                     ctxC.stroke();
  312.                     ctxC.restore();
  313.                 }

  314.                 // 当前角度弧
  315.                 ctxC.save();
  316.                 ctxC.strokeStyle = '#ff9f1a';
  317.                 ctxC.lineWidth = 3;
  318.                 ctxC.beginPath();
  319.                 ctxC.arc(cx, cy, unitPx*0.9, 0, remainderRad, remainder < 0);
  320.                 ctxC.stroke();
  321.                 ctxC.restore();

  322.                 // 角度线
  323.                 ctxC.save();
  324.                 ctxC.strokeStyle = '#ff9f1a';
  325.                 ctxC.lineWidth = 2;
  326.                 ctxC.beginPath();
  327.                 ctxC.moveTo(cx, cy);
  328.                 ctxC.lineTo(sx(Math.cos(remainderRad)), sy(Math.sin(remainderRad)));
  329.                 ctxC.stroke();
  330.                 ctxC.restore();

  331.                 // 当前点
  332.                 ctxC.save();
  333.                 ctxC.fillStyle = getVar('--point');
  334.                 ctxC.beginPath();
  335.                 ctxC.arc(sx(Math.cos(rad)), sy(Math.sin(rad)), 6, 0, Math.PI*2);
  336.                 ctxC.fill();
  337.                 ctxC.restore();

  338.                 // 标注
  339.                 if (state.showAnnotations) {
  340.                     ctxC.save();
  341.                     ctxC.fillStyle = getVar('--ink');
  342.                     ctxC.font = '12px sans-serif';
  343.                     ctxC.textAlign = 'center';
  344.                     ctxC.textBaseline = 'middle';
  345.                     ctxC.fillText('0°', sx(1.2), sy(0));
  346.                     ctxC.fillText('90°', sx(0), sy(1.2));
  347.                     ctxC.fillText('180°', sx(-1.2), sy(0));
  348.                     ctxC.fillText('270°', sx(0), sy(-1.2));
  349.                     ctxC.restore();
  350.                 }
  351.             }

  352.             // 绘制三角函数图像
  353.             function drawPlot(){
  354.                 const rect = plot.getBoundingClientRect();
  355.                 const w = rect.width, h = rect.height;
  356.                 const cx = w/2, cy = h/2;

  357.                 // 坐标变换
  358.                 const xToPx = x => cx + x * (w - 2*MARGIN) / (Math.PI * 4);
  359.                 const yToPx = y => cy - y * (h - 2*MARGIN) / 4;

  360.                 // 背景
  361.                 ctxP.clearRect(0,0,w,h);
  362.                 ctxP.fillStyle = '#ffffff';
  363.                 ctxP.fillRect(0,0,w,h);

  364.                 // 网格
  365.                 if (state.showGrid){
  366.                     ctxP.save();
  367.                     ctxP.strokeStyle = '#f3f3f3';
  368.                     ctxP.lineWidth = 1;
  369.                     ctxP.beginPath();
  370.                     // 垂直网格线(π/2间隔)
  371.                     for(let x = -2*Math.PI; x <= 2*Math.PI; x += Math.PI/2) {
  372.                         ctxP.moveTo(xToPx(x), yToPx(-2));
  373.                         ctxP.lineTo(xToPx(x), yToPx(2));
  374.                     }
  375.                     // 水平网格线(0.5间隔)
  376.                     for(let y = -2; y <= 2; y += 0.5) {
  377.                         ctxP.moveTo(xToPx(-2*Math.PI), yToPx(y));
  378.                         ctxP.lineTo(xToPx(2*Math.PI), yToPx(y));
  379.                     }
  380.                     ctxP.stroke();
  381.                     ctxP.restore();
  382.                 }

  383.                 // 坐标轴
  384.                 ctxP.save();
  385.                 ctxP.strokeStyle = getVar('--axes');
  386.                 ctxP.lineWidth = 2;
  387.                 ctxP.beginPath();
  388.                 ctxP.moveTo(xToPx(-2*Math.PI), yToPx(0));
  389.                 ctxP.lineTo(xToPx(2*Math.PI), yToPx(0));
  390.                 ctxP.moveTo(xToPx(0), yToPx(-2));
  391.                 ctxP.lineTo(xToPx(0), yToPx(2));
  392.                 ctxP.stroke();
  393.                 ctxP.restore();

  394.                 // 函数曲线
  395.                 const rad = deg2rad(state.theta);
  396.                 ctxP.save();
  397.                 ctxP.strokeStyle = colorFor(state.func);
  398.                 ctxP.lineWidth = 3;
  399.                 ctxP.beginPath();

  400.                 let firstPoint = true;
  401.                 for(let x = -2*Math.PI; x <= 2*Math.PI; x += 0.01) {
  402.                     let y;
  403.                     if (state.func === 'cos') y = Math.cos(x);
  404.                     else if (state.func === 'sin') y = Math.sin(x);
  405.                     else y = Math.tan(x);

  406.                     // 限制tan函数的值域显示
  407.                     if (state.func === 'tan' && Math.abs(y) > 10) {
  408.                         firstPoint = true;
  409.                         continue;
  410.                     }

  411.                     if (firstPoint) {
  412.                         ctxP.moveTo(xToPx(x), yToPx(y));
  413.                         firstPoint = false;
  414.                     } else {
  415.                         ctxP.lineTo(xToPx(x), yToPx(y));
  416.                     }
  417.                 }
  418.                 ctxP.stroke();
  419.                 ctxP.restore();

  420.                 // 当前角度标记
  421.                 ctxP.save();
  422.                 ctxP.fillStyle = getVar('--point');
  423.                 ctxP.beginPath();
  424.                 let yVal;
  425.                 if (state.func === 'cos') yVal = Math.cos(rad);
  426.                 else if (state.func === 'sin') yVal = Math.sin(rad);
  427.                 else yVal = Math.tan(rad);
  428.                
  429.                 if (isFinite(yVal)) {
  430.                     ctxP.arc(xToPx(rad), yToPx(yVal), 6, 0, Math.PI*2);
  431.                     ctxP.fill();
  432.                 }
  433.                 ctxP.restore();

  434.                 // 标注
  435.                 if (state.showAnnotations) {
  436.                     ctxP.save();
  437.                     ctxP.fillStyle = getVar('--ink');
  438.                     ctxP.font = '12px sans-serif';
  439.                     ctxP.textAlign = 'center';
  440.                     
  441.                     // x轴标注
  442.                     ctxP.textBaseline = 'top';
  443.                     for(let x = -2*Math.PI; x <= 2*Math.PI; x += Math.PI/2) {
  444.                         let label;
  445.                         if (x === 0) label = '0';
  446.                         else if (x === Math.PI/2) label = 'π/2';
  447.                         else if (x === Math.PI) label = 'π';
  448.                         else if (x === 3*Math.PI/2) label = '3π/2';
  449.                         else if (x === 2*Math.PI) label = '2π';
  450.                         else if (x === -Math.PI/2) label = '-π/2';
  451.                         else if (x === -Math.PI) label = '-π';
  452.                         else if (x === -3*Math.PI/2) label = '-3π/2';
  453.                         else if (x === -2*Math.PI) label = '-2π';
  454.                         else continue;
  455.                         
  456.                         ctxP.fillText(label, xToPx(x), yToPx(0) + 5);
  457.                     }
  458.                     
  459.                     // y轴标注
  460.                     ctxP.textAlign = 'right';
  461.                     ctxP.textBaseline = 'middle';
  462.                     for(let y = -2; y <= 2; y += 1) {
  463.                         if (y === 0) continue;
  464.                         ctxP.fillText(y.toString(), xToPx(0) - 5, yToPx(y));
  465.                     }
  466.                     ctxP.restore();
  467.                 }
  468.             }

  469.             function drawAll(){
  470.                 drawCircle();
  471.                 drawPlot();
  472.             }

  473.             // 重置
  474.             btnReset.addEventListener('click', ()=>{
  475.                 state.theta = 0;
  476.                 state.func = 'cos';
  477.                 state.unit = 'deg';
  478.                 thetaRange.value = 0;
  479.                 radiosFunc.forEach(r => r.checked = (r.value==='cos'));
  480.                 radiosUnit.forEach(r => r.checked = (r.value==='deg'));
  481.                 state.showGrid = true;  chkGrid.checked = true;
  482.                 state.showAnnotations = true; chkAnno.checked = true;
  483.                 syncUI();
  484.                 drawAll();
  485.             });

  486.             // 下载 PNG
  487.             btnSave.addEventListener('click', ()=>{
  488.                 drawAll();

  489.                 const cw = circle.getBoundingClientRect().width;
  490.                 const ch = circle.getBoundingClientRect().height;
  491.                 const pw = plot.getBoundingClientRect().width;
  492.                 const ph = plot.getBoundingClientRect().height;

  493.                 const W = Math.max(cw, pw);
  494.                 const H = ch + 10 + ph;

  495.                 const off = document.createElement('canvas');
  496.                 off.width = W; off.height = H;
  497.                 const g = off.getContext('2d');
  498.                 g.fillStyle = '#fff'; g.fillRect(0,0,W,H);

  499.                 const cImg = new Image();
  500.                 const pImg = new Image();
  501.                 const once = (img, src) => new Promise(res => { img.onload = res; img.src = src; });
  502.                 Promise.all([
  503.                     once(cImg, circle.toDataURL('image/png')),
  504.                     once(pImg, plot.toDataURL('image/png'))
  505.                 ]).then(()=>{
  506.                     const cx = (W - cw)/2;
  507.                     const px = (W - pw)/2;
  508.                     g.drawImage(cImg, cx, 0, cw, ch);
  509.                     g.drawImage(pImg, px, ch + 10, pw, ph);

  510.                     const a = document.createElement('a');
  511.                     a.download = `trig_${state.func}_theta${state.theta.toFixed(1)}.png`;
  512.                     a.href = off.toDataURL('image/png');
  513.                     a.click();
  514.                 });
  515.             });

  516.             // 启动
  517.             resizeAll();
  518.             syncUI();
  519.             drawAll();
  520.         })();
  521.     </script>
  522. </body>
  523. </html>
复制代码


使用说明:
  • 在单位圆上拖动可以改变角度(支持多圈旋转)
  • 使用滑块可以精确控制角度值(-720° 到 720°)
  • 选择不同的三角函数(cos/sin/tan)查看对应曲线
  • 可以切换角度显示单位(度/弧度)
  • 右侧面板可以控制网格和标识的显示
  • 支持重置和保存图片功能


这个工具可以帮助直观理解三角函数与单位圆的关系,适合数学学习和教学使用。

以上回复来自 FishC AI 小助理,如未能正确解答您的问题,请继续追问。
小甲鱼最新课程 -> https://ilovefishc.com
回复 支持 反对

使用道具 举报

发表于 前天 18:27 | 显示全部楼层
给力啊,继续加油!
小甲鱼最新课程 -> https://ilovefishc.com
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Archiver|鱼C工作室 ( 粤ICP备18085999号-1 | 粤公网安备 44051102000585号)

GMT+8, 2025-9-4 20:36

Powered by Discuz! X3.4

© 2001-2023 Discuz! Team.

快速回复 返回顶部 返回列表