匿名鱼油 发表于 2025-9-1 18:42:42

三角函数可视化



作品介绍:

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

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



<!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:#f7ebc5;
    --panel:#ffffff;
    --ink:#222;
    --grid:#ececec;
    --axes:#333;
    --cos:#1f6afe;
    --sin:#1fbf6b;
    --tan:#ff6a3d;
    --point:#e03131;
    --shadow:0 6px 18px rgba(0,0,0,.08);
    --radius:12px;
}
*{box-sizing:border-box}
body{
    margin:0;
    background:var(--bg);
    color:var(--ink);
    font:14px/1.45 system-ui,-apple-system,Segoe UI,Roboto,"PingFang SC","Microsoft YaHei","Noto Sans",Arial,sans-serif;
}
.app{
    min-height:100vh;
    padding:14px;
    display:grid;
    grid-template-columns: 280px 1fr 240px;
    grid-template-rows: auto 1fr;
    gap:14px;
}
@media (max-width: 1080px){
    .app{
      grid-template-columns: 1fr;
      grid-template-rows: auto auto auto;
    }
}
.card{
    background:var(--panel);
    border-radius:var(--radius);
    box-shadow:var(--shadow);
    padding:12px;
}
.panel-left{grid-column:1;grid-row:1/span 2}
.center-top{grid-column:2;grid-row:1}
.center-bottom{grid-column:2;grid-row:2}
.panel-right{grid-column:3;grid-row:1/span 2}
@media (max-width:1080px){
    .panel-left{grid-column:1;grid-row:auto}
    .center-top{grid-column:1}
    .center-bottom{grid-column:1}
    .panel-right{grid-column:1}
}

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

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

.ctrls{display:grid;gap:10px}
.ctrls .row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
label{user-select:none}
input{width:100%}
button{padding:8px 12px;border-radius:10px;border:1px solid #d6dde4;background:#fff;cursor:pointer}
button.primary{background:linear-gradient(180deg,#ffd27d,#ff9f1a);color:#222;border:none}
</style>
</head>
<body>
<div class="app">
    <!-- 左侧数值面板 -->
    <section class="card panel-left">
      <h3>值</h3>
      <div class="kv">
      <div>角</div><div id="angleRead" class="mono">0.0°</div>
      <div>(x, y)</div><div id="xyRead" class="mono">(1.000, 0.000)</div>
      <div id="exprName" class="mono">cosθ =</div>
      <div id="exprVal" class="mono">x / 1 = 1.000</div>
      </div>
      <hr/>
      <div>
      <label><input type="radio" name="unit" value="deg" checked> 度</label>
      <label style="margin-left:12px"><input type="radio" name="unit" value="rad"> 弧度</label>
      </div>
    </section>

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

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

    <!-- 右侧控制 -->
    <aside class="card panel-right">
      <h3>显示</h3>
      <div class="ctrls">
      <div>
          <label><input type="radio" name="func" value="cos" checked> <span style="color:var(--cos)">cos</span></label>
          <label style="margin-left:12px"><input type="radio" name="func" value="sin"> <span style="color:var(--sin)">sin</span></label>
          <label style="margin-left:12px"><input type="radio" name="func" value="tan"> <span style="color:var(--tan)">tan</span></label>
      </div>
      <div class="row">
          <label><input type="checkbox" id="annotations" checked> 标识</label>
          <label><input type="checkbox" id="grid" checked> 网格</label>
      </div>
      <hr/>
      <div class="row" style="align-items:center">
          <label style="width:56px">角度</label>
          <input type="range" id="thetaRange" min="-720" max="720" step="0.1" value="0" />
          <span class="mono" id="thetaLabel">0°</span>
      </div>
      <div class="row">
          <button id="reset">重置</button>
          <button id="save" class="primary">下载 PNG</button>
      </div>
      </div>
    </aside>
</div>

<script>
(() => {
// 视角:单位圆视图
const VIEW_RANGE = 2;
const GRID_STEP = 0.5;
const MARGIN = 18;

// 状态
const state = {
    theta: 0,            // 角度(度),可达 ±720°
    func: 'cos',
    unit: 'deg',
    showGrid: true,
    showAnnotations: true
};

// DOM
const circle = document.getElementById('circle');
const plot = document.getElementById('plot');
const ctxC = circle.getContext('2d');
const ctxP = plot.getContext('2d');

const thetaRange = document.getElementById('thetaRange');
const thetaLabel = document.getElementById('thetaLabel');
const angleRead = document.getElementById('angleRead');
const xyRead = document.getElementById('xyRead');
const exprName = document.getElementById('exprName');
const exprVal = document.getElementById('exprVal');

const radiosFunc = [...document.querySelectorAll('input')];
const radiosUnit = [...document.querySelectorAll('input')];
const chkGrid   = document.getElementById('grid');
const chkAnno   = document.getElementById('annotations');

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

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

// 自适应画布
function fitCanvas(canvas, ctx){
    const rect = canvas.getBoundingClientRect();
    const dpr = Math.max(1, window.devicePixelRatio || 1);
    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);
}
function resizeAll(){
    fitCanvas(circle, ctxC);
    fitCanvas(plot, ctxP);
    drawAll();
}
window.addEventListener('resize', resizeAll, {passive:true});

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

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

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

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

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

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

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

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

      thetaRange.value = state.theta;
      syncUI();
      drawAll();
    }

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

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

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

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

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

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

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

    // 网格
    if (state.showGrid){
      ctxC.save();
      ctxC.strokeStyle = '#f3f3f3';
      ctxC.lineWidth = 1;
      ctxC.beginPath();
      for(let x = -VIEW_RANGE; x <= VIEW_RANGE + 1e-9; x += GRID_STEP){
      const X = Math.round(sx(x)) + 0.5;
      ctxC.moveTo(X, 0); ctxC.lineTo(X, h);
      }
      for(let y = -VIEW_RANGE; y <= VIEW_RANGE + 1e-9; y += GRID_STEP){
      const Y = Math.round(sy(y)) + 0.5;
      ctxC.moveTo(0, Y); ctxC.lineTo(w, Y);
      }
      ctxC.stroke();
      ctxC.restore();
    }

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

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

    // 刻度(主整数,次 0.5)
    ctxC.strokeStyle = '#8b98a8';
    ctxC.lineWidth = 1;
    for (let v=-VIEW_RANGE; v<=VIEW_RANGE+1e-9; v+=GRID_STEP){
      const X = sx(v), len = (Math.abs(v % 1) < 1e-9) ? 6 : 3;
      ctxC.beginPath(); ctxC.moveTo(X, y0-len); ctxC.lineTo(X, y0+len); ctxC.stroke();
    }
    for (let v=-VIEW_RANGE; v<=VIEW_RANGE+1e-9; v+=GRID_STEP){
      const Y = sy(v), len = (Math.abs(v % 1) < 1e-9) ? 6 : 3;
      ctxC.beginPath(); ctxC.moveTo(x0-len, Y); ctxC.lineTo(x0+len, Y); ctxC.stroke();
    }
    // 标签
    ctxC.fillStyle = '#445';
    ctxC.font = '12px system-ui, -apple-system, Segoe UI, Roboto';
    ctxC.textAlign = 'center'; ctxC.textBaseline = 'top';
    for (let v=-VIEW_RANGE; v<=VIEW_RANGE; v++){
      const X = sx(v); ctxC.fillText(String(v), X, y0 + 8);
    }
    ctxC.textAlign = 'right'; ctxC.textBaseline = 'middle';
    for (let v=-VIEW_RANGE; v<=VIEW_RANGE; v++){
      const Y = sy(v); ctxC.fillText(String(v), x0 - 8, Y);
    }
    ctxC.textAlign = 'left'; ctxC.textBaseline = 'top';
    ctxC.fillText('0', x0 + 6, y0 + 6);
    ctxC.textAlign = 'right'; ctxC.textBaseline = 'top'; ctxC.fillText('x', w - MARGIN/2 - 6, y0 + 6);
    ctxC.textAlign = 'left'; ctxC.textBaseline = 'bottom'; ctxC.fillText('y', x0 + 6, MARGIN/2 + 12);
    ctxC.restore();

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

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

    // 半径线
    ctxC.beginPath();
    ctxC.moveTo(sx(0), sy(0));
    ctxC.lineTo(px, py);
    ctxC.strokeStyle = '#2b60ff';
    ctxC.lineWidth = 2;
    ctxC.stroke();

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

    // 投影线
    if (state.showAnnotations){
      ctxC.save();
      ctxC.setLineDash();
      ctxC.strokeStyle = '#000';
      ctxC.lineWidth = 1.2;
      ctxC.beginPath(); ctxC.moveTo(px, py); ctxC.lineTo(px, sy(0)); ctxC.stroke();
      ctxC.beginPath(); ctxC.moveTo(px, py); ctxC.lineTo(sx(0), py); ctxC.stroke();
      ctxC.restore();
    }

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

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

    ctx.save();
    ctx.strokeStyle = '#2b60ff';
    ctx.lineWidth = 2;
    ctx.lineCap = 'round';

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

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

    ctx.restore();
}

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

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

    ctx.beginPath();
    ctx.moveTo(xPrev, yPrev);
    for (let i=1;i<=steps;i++){
      const t = theta * (i/steps);
      const x = cx + r * Math.cos(t);
      const y = cy - r * Math.sin(t);
      xPrev = xCurr; yPrev = yCurr;
      xCurr = x;   yCurr = y;
      ctx.lineTo(x, y);
    }
    ctx.stroke();

    // 箭头
    const vx = xCurr - xPrev;
    const vy = yCurr - yPrev;
    const len = Math.hypot(vx, vy) || 1;
    const ux = vx/len, uy = vy/len;
    const ah = 8, aw = 6;
    ctx.fillStyle = '#2b60ff';
    ctx.beginPath();
    ctx.moveTo(xCurr, yCurr);
    ctx.lineTo(xCurr - ux*ah + (-uy)*aw, yCurr - uy*ah + (ux)*aw);
    ctx.lineTo(xCurr - ux*ah - (-uy)*aw, yCurr - uy*ah - (ux)*aw);
    ctx.closePath();
    ctx.fill();

    ctx.restore();
}

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

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

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

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

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

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

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

      // 横向网格
      ctxP.beginPath();
      if (state.func==='tan'){
      for (let v=-3; v<=3; v++){
          const y = sy(v);
          ctxP.moveTo(left, y); ctxP.lineTo(right, y);
      }
      }else{
      const ys = [-1,-0.5,0,0.5,1];
      ys.forEach(v=>{ const y=sy(v); ctxP.moveTo(left,y); ctxP.lineTo(right,y); });
      }
      ctxP.stroke();
      ctxP.restore();
    }

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

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

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

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

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

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

    // tan 渐近线
    if (state.func==='tan'){
      ctxP.save();
      ctxP.setLineDash();
      ctxP.strokeStyle = '#f1a47f';
      ctxP.lineWidth = 1.2;
      const step = Math.PI;
      const start = Math.ceil((xMin - Math.PI/2)/step)*step + Math.PI/2;
      for (let x = start; x <= xMax; x += step){
      const X = sx(x);
      ctxP.beginPath(); ctxP.moveTo(X, top); ctxP.lineTo(X, bottom); ctxP.stroke();
      }
      ctxP.restore();
    }

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

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

    const samples = Math.max(240, Math.floor(innerW));
    let first = true;
    let lastY = null;
    ctxP.beginPath();
    for(let i=0;i<=samples;i++){
      const t = xMin + (i/samples)*(xMax-xMin);
      let y = fn(t);
      if (state.func==='tan'){
      if (!isFinite(y) || Math.abs(Math.cos(t))<0.015){ lastY=null; first=true; continue; }
      y = Math.max(-3, Math.min(3, y));
      }
      const X = sx(t), Y = sy(y);
      if (first){ ctxP.moveTo(X, Y); first = false; }
      else{
      if (state.func==='tan' && lastY !== null && Math.abs(Y - lastY) > innerH*0.5){
          ctxP.moveTo(X, Y);
      }else ctxP.lineTo(X, Y);
      }
      lastY = Y;
    }
    ctxP.stroke();
    ctxP.restore();

    // 当前 θ 的标记
    const fx = fn(theta0);
    const X0 = sx(theta0);
    if (state.showAnnotations){
      ctxP.save();
      ctxP.setLineDash();
      ctxP.strokeStyle = '#999';
      ctxP.beginPath(); ctxP.moveTo(X0, top); ctxP.lineTo(X0, bottom); ctxP.stroke();
      ctxP.restore();
    }
    if (isFinite(fx) && !(state.func==='tan' && Math.abs(Math.cos(theta0))<1e-6)){
      const Y0 = sy(Math.max(-3, Math.min(3, fx)));
      ctxP.beginPath();
      ctxP.arc(X0, Y0, 5, 0, Math.PI*2);
      ctxP.fillStyle = getVar('--point');
      ctxP.strokeStyle = '#fff';
      ctxP.lineWidth = 2;
      ctxP.fill(); ctxP.stroke();
    }
}

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

// 画箭头
function drawArrow(ctx, x, y, dir, color){
    ctx.save();
    ctx.fillStyle = color || '#333';
    const L = 10, W = 6;
    ctx.beginPath();
    if (dir === 'right'){
      ctx.moveTo(x, y);
      ctx.lineTo(x - L, y - W/2);
      ctx.lineTo(x - L, y + W/2);
    }else if (dir === 'up'){
      ctx.moveTo(x, y);
      ctx.lineTo(x - W/2, y + L);
      ctx.lineTo(x + W/2, y + L);
    }
    ctx.closePath();
    ctx.fill();
    ctx.restore();
}

function drawAll(){
    drawCircle();
    drawPlot();
}

// 重置
btnReset.addEventListener('click', ()=>{
    state.theta = 0;
    thetaRange.value = 0;
    state.func = 'cos';
    radiosFunc.forEach(r=> r.checked = (r.value==='cos'));
    state.unit = 'deg';
    radiosUnit.forEach(r=> r.checked = (r.value==='deg'));
    state.showGrid = true;chkGrid.checked = true;
    state.showAnnotations = true; chkAnno.checked = true;
    syncUI();
    drawAll();
});

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

    const cw = circle.getBoundingClientRect().width;
    const ch = circle.getBoundingClientRect().height;
    const pw = plot.getBoundingClientRect().width;
    const ph = plot.getBoundingClientRect().height;

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

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

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

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

// 启动
resizeAll();
syncUI();
drawAll();
})();
</script>
</body>
</html>


BrendanEich 发表于 2025-9-1 18:47:55

三角函数可视化工具说明

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

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>三角函数可视化</title>
    <style>
      :root {
            --bg: #f7ebc5;
            --panel: #ffffff;
            --ink: #222;
            --grid: #ececec;
            --axes: #333;
            --cos: #1f6afe;
            --sin: #1fbf6b;
            --tan: #ff6a3d;
            --point: #e03131;
            --shadow: 0 6px 18px rgba(0,0,0,.08);
            --radius: 12px;
      }
      * { box-sizing: border-box; }
      body {
            margin: 0;
            background: var(--bg);
            color: var(--ink);
            font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, "PingFang SC", "Microsoft YaHei", "Noto Sans", Arial, sans-serif;
      }
      .app {
            min-height: 100vh;
            padding: 14px;
            display: grid;
            grid-template-columns: 280px 1fr 240px;
            grid-template-rows: auto 1fr;
            gap: 14px;
      }
      @media (max-width: 1080px) {
            .app {
                grid-template-columns: 1fr;
                grid-template-rows: auto auto auto;
            }
      }
      .card {
            background: var(--panel);
            border-radius: var(--radius);
            box-shadow: var(--shadow);
            padding: 12px;
      }
      .panel-left { grid-column: 1; grid-row: 1/span 2; }
      .center-top { grid-column: 2; grid-row: 1; }
      .center-bottom { grid-column: 2; grid-row: 2; }
      .panel-right { grid-column: 3; grid-row: 1/span 2; }
      @media (max-width:1080px) {
            .panel-left { grid-column: 1; grid-row: auto; }
            .center-top { grid-column: 1; }
            .center-bottom { grid-column: 1; }
            .panel-right { grid-column: 1; }
      }

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

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

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

      <!-- 中间上方:单位圆 -->
      <div class="card center-top">
            <div class="canvas-wrap">
                <canvas id="circle"></canvas>
            </div>
      </div>

      <!-- 中间下方:三角函数图像 -->
      <div class="card center-bottom">
            <div class="plot-wrap">
                <canvas id="plot"></canvas>
            </div>
      </div>

      <!-- 右侧控制面板 -->
      <div class="card panel-right">
            <h3>显示</h3>
            <div class="ctrls">
                <div class="row">
                  <label><input type="radio" name="func" value="cos" checked> cos</label>
                  <label><input type="radio" name="func" value="sin"> sin</label>
                  <label><input type="radio" name="func" value="tan"> tan</label>
                </div>
                <div class="row">
                  <label><input type="radio" name="unit" value="deg" checked> 度</label>
                  <label><input type="radio" name="unit" value="rad"> 弧度</label>
                </div>
                <div class="row">
                  <label><input type="checkbox" id="grid" checked> 网格</label>
                  <label><input type="checkbox" id="annotations" checked> 标识</label>
                </div>
                <div class="row">
                  <label for="thetaRange">角度</label>
                  <span id="thetaLabel">0°</span>
                </div>
                <input type="range" id="thetaRange" min="-720" max="720" value="0" step="0.1">
                <div class="row">
                  <button id="reset">重置</button>
                  <button id="save" class="primary">下载 PNG</button>
                </div>
            </div>
      </div>
    </div>

    <script>
      (() => {
            // 视角:单位圆视图
            const VIEW_RANGE = 2;
            const GRID_STEP = 0.5;
            const MARGIN = 18;

            // 状态
            const state = {
                theta: 0,            // 角度(度),可达 ±720°
                func: 'cos',
                unit: 'deg',
                showGrid: true,
                showAnnotations: true
            };

            // DOM
            const circle = document.getElementById('circle');
            const plot = document.getElementById('plot');
            const ctxC = circle.getContext('2d');
            const ctxP = plot.getContext('2d');

            const thetaRange = document.getElementById('thetaRange');
            const thetaLabel = document.getElementById('thetaLabel');
            const angleRead = document.getElementById('angleRead');
            const xyRead = document.getElementById('xyRead');
            const exprName = document.getElementById('exprName');
            const exprVal = document.getElementById('exprVal');

            const radiosFunc = document.querySelectorAll('input');
            const radiosUnit = document.querySelectorAll('input');
            const chkGrid   = document.getElementById('grid');
            const chkAnno   = document.getElementById('annotations');

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

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

            // 自适应画布
            function fitCanvas(canvas, ctx){
                const rect = canvas.getBoundingClientRect();
                const dpr = Math.max(1, window.devicePixelRatio || 1);
                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);
            }
            function resizeAll(){
                fitCanvas(circle, ctxC);
                fitCanvas(plot, ctxP);
                drawAll();
            }
            window.addEventListener('resize', resizeAll, {passive:true});

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

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

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

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

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

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

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

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

                  thetaRange.value = state.theta;
                  syncUI();
                  drawAll();
                }

                circle.addEventListener('pointerdown', ev=>{
                  dragging = true;
                  circle.setPointerCapture(ev.pointerId);
                  lastRawDeg = rawDegFromEvent(ev);
                });
                circle.addEventListener('pointermove', onMove);
                circle.addEventListener('pointerup',   ()=> dragging = false);
                circle.addEventListener('pointercancel',()=> dragging = false);
            })();

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

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

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

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

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

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

                // 网格
                if (state.showGrid){
                  ctxC.save();
                  ctxC.strokeStyle = '#f3f3f3';
                  ctxC.lineWidth = 1;
                  ctxC.beginPath();
                  for(let x = -VIEW_RANGE; x <= VIEW_RANGE; x += GRID_STEP) {
                        ctxC.moveTo(sx(x), sy(-VIEW_RANGE));
                        ctxC.lineTo(sx(x), sy(VIEW_RANGE));
                  }
                  for(let y = -VIEW_RANGE; y <= VIEW_RANGE; y += GRID_STEP) {
                        ctxC.moveTo(sx(-VIEW_RANGE), sy(y));
                        ctxC.lineTo(sx(VIEW_RANGE), sy(y));
                  }
                  ctxC.stroke();
                  ctxC.restore();
                }

                // 坐标轴
                ctxC.save();
                ctxC.strokeStyle = getVar('--axes');
                ctxC.lineWidth = 2;
                ctxC.beginPath();
                ctxC.moveTo(sx(-VIEW_RANGE), sy(0));
                ctxC.lineTo(sx(VIEW_RANGE), sy(0));
                ctxC.moveTo(sx(0), sy(-VIEW_RANGE));
                ctxC.lineTo(sx(0), sy(VIEW_RANGE));
                ctxC.stroke();
                ctxC.restore();

                // 单位圆
                ctxC.save();
                ctxC.strokeStyle = '#999';
                ctxC.lineWidth = 2;
                ctxC.beginPath();
                ctxC.arc(cx, cy, unitPx, 0, Math.PI*2);
                ctxC.stroke();
                ctxC.restore();

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

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

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

                // 角度线
                ctxC.save();
                ctxC.strokeStyle = '#ff9f1a';
                ctxC.lineWidth = 2;
                ctxC.beginPath();
                ctxC.moveTo(cx, cy);
                ctxC.lineTo(sx(Math.cos(remainderRad)), sy(Math.sin(remainderRad)));
                ctxC.stroke();
                ctxC.restore();

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

                // 标注
                if (state.showAnnotations) {
                  ctxC.save();
                  ctxC.fillStyle = getVar('--ink');
                  ctxC.font = '12px sans-serif';
                  ctxC.textAlign = 'center';
                  ctxC.textBaseline = 'middle';
                  ctxC.fillText('0°', sx(1.2), sy(0));
                  ctxC.fillText('90°', sx(0), sy(1.2));
                  ctxC.fillText('180°', sx(-1.2), sy(0));
                  ctxC.fillText('270°', sx(0), sy(-1.2));
                  ctxC.restore();
                }
            }

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

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

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

                // 网格
                if (state.showGrid){
                  ctxP.save();
                  ctxP.strokeStyle = '#f3f3f3';
                  ctxP.lineWidth = 1;
                  ctxP.beginPath();
                  // 垂直网格线(π/2间隔)
                  for(let x = -2*Math.PI; x <= 2*Math.PI; x += Math.PI/2) {
                        ctxP.moveTo(xToPx(x), yToPx(-2));
                        ctxP.lineTo(xToPx(x), yToPx(2));
                  }
                  // 水平网格线(0.5间隔)
                  for(let y = -2; y <= 2; y += 0.5) {
                        ctxP.moveTo(xToPx(-2*Math.PI), yToPx(y));
                        ctxP.lineTo(xToPx(2*Math.PI), yToPx(y));
                  }
                  ctxP.stroke();
                  ctxP.restore();
                }

                // 坐标轴
                ctxP.save();
                ctxP.strokeStyle = getVar('--axes');
                ctxP.lineWidth = 2;
                ctxP.beginPath();
                ctxP.moveTo(xToPx(-2*Math.PI), yToPx(0));
                ctxP.lineTo(xToPx(2*Math.PI), yToPx(0));
                ctxP.moveTo(xToPx(0), yToPx(-2));
                ctxP.lineTo(xToPx(0), yToPx(2));
                ctxP.stroke();
                ctxP.restore();

                // 函数曲线
                const rad = deg2rad(state.theta);
                ctxP.save();
                ctxP.strokeStyle = colorFor(state.func);
                ctxP.lineWidth = 3;
                ctxP.beginPath();

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

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

                  if (firstPoint) {
                        ctxP.moveTo(xToPx(x), yToPx(y));
                        firstPoint = false;
                  } else {
                        ctxP.lineTo(xToPx(x), yToPx(y));
                  }
                }
                ctxP.stroke();
                ctxP.restore();

                // 当前角度标记
                ctxP.save();
                ctxP.fillStyle = getVar('--point');
                ctxP.beginPath();
                let yVal;
                if (state.func === 'cos') yVal = Math.cos(rad);
                else if (state.func === 'sin') yVal = Math.sin(rad);
                else yVal = Math.tan(rad);
               
                if (isFinite(yVal)) {
                  ctxP.arc(xToPx(rad), yToPx(yVal), 6, 0, Math.PI*2);
                  ctxP.fill();
                }
                ctxP.restore();

                // 标注
                if (state.showAnnotations) {
                  ctxP.save();
                  ctxP.fillStyle = getVar('--ink');
                  ctxP.font = '12px sans-serif';
                  ctxP.textAlign = 'center';
                  
                  // x轴标注
                  ctxP.textBaseline = 'top';
                  for(let x = -2*Math.PI; x <= 2*Math.PI; x += Math.PI/2) {
                        let label;
                        if (x === 0) label = '0';
                        else if (x === Math.PI/2) label = 'π/2';
                        else if (x === Math.PI) label = 'π';
                        else if (x === 3*Math.PI/2) label = '3π/2';
                        else if (x === 2*Math.PI) label = '2π';
                        else if (x === -Math.PI/2) label = '-π/2';
                        else if (x === -Math.PI) label = '-π';
                        else if (x === -3*Math.PI/2) label = '-3π/2';
                        else if (x === -2*Math.PI) label = '-2π';
                        else continue;
                        
                        ctxP.fillText(label, xToPx(x), yToPx(0) + 5);
                  }
                  
                  // y轴标注
                  ctxP.textAlign = 'right';
                  ctxP.textBaseline = 'middle';
                  for(let y = -2; y <= 2; y += 1) {
                        if (y === 0) continue;
                        ctxP.fillText(y.toString(), xToPx(0) - 5, yToPx(y));
                  }
                  ctxP.restore();
                }
            }

            function drawAll(){
                drawCircle();
                drawPlot();
            }

            // 重置
            btnReset.addEventListener('click', ()=>{
                state.theta = 0;
                state.func = 'cos';
                state.unit = 'deg';
                thetaRange.value = 0;
                radiosFunc.forEach(r => r.checked = (r.value==='cos'));
                radiosUnit.forEach(r => r.checked = (r.value==='deg'));
                state.showGrid = true;chkGrid.checked = true;
                state.showAnnotations = true; chkAnno.checked = true;
                syncUI();
                drawAll();
            });

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

                const cw = circle.getBoundingClientRect().width;
                const ch = circle.getBoundingClientRect().height;
                const pw = plot.getBoundingClientRect().width;
                const ph = plot.getBoundingClientRect().height;

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

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

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

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

            // 启动
            resizeAll();
            syncUI();
            drawAll();
      })();
    </script>
</body>
</html>

使用说明:

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


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

以上回复来自 FishC AI 小助理,如未能正确解答您的问题,请继续追问。

小甲鱼 发表于 2025-9-2 18:27:38

给力啊,继续加油!
页: [1]
查看完整版本: 三角函数可视化