三角函数可视化
作品介绍:
通过鼠标可以在圆中转动角度,在下图中的三角函数图像上显示该角度所在的位置
在左侧的小面板上显示坐标位置、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>
三角函数可视化工具说明
这是一个基于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 小助理,如未能正确解答您的问题,请继续追问。 给力啊,继续加油!
页:
[1]