af78e5d0e6
- 实现基于 Canvas 的矩形切割可视化,直观展示 GCD 求解过程 - 支持手动逐步演示和自动播放两种模式 - 添加随机示例功能,内置多组经典用例 - 当方块数量过多时自动简化绘制,避免性能问题
521 lines
13 KiB
HTML
521 lines
13 KiB
HTML
<!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>
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||
background: #f0f4f8;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
min-height: 100vh;
|
||
padding: 20px;
|
||
}
|
||
.container {
|
||
max-width: 900px;
|
||
width: 100%;
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
padding: 28px 30px 20px;
|
||
box-shadow: 0 8px 30px rgba(0,0,0,.08);
|
||
}
|
||
h1 {
|
||
text-align: center;
|
||
margin: 0 0 4px;
|
||
color: #2c3e50;
|
||
}
|
||
.subtitle {
|
||
text-align: center;
|
||
color: #7f8c8d;
|
||
margin: 0 0 18px;
|
||
font-size: 16px;
|
||
}
|
||
code {
|
||
background: #ecf0f1;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
font-family: 'Cascadia Mono', Consolas, monospace;
|
||
}
|
||
.controls {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
justify-content: center;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
.controls label {
|
||
font-size: 16px;
|
||
color: #2c3e50;
|
||
}
|
||
.controls input {
|
||
width: 90px;
|
||
padding: 7px 10px;
|
||
font-size: 16px;
|
||
border: 2px solid #dce1e6;
|
||
border-radius: 8px;
|
||
text-align: center;
|
||
transition: border-color .2s;
|
||
}
|
||
.controls input:focus {
|
||
outline: none;
|
||
border-color: #3498db;
|
||
}
|
||
button {
|
||
padding: 8px 18px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: #3498db;
|
||
color: #fff;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
transition: background .2s, opacity .2s;
|
||
}
|
||
button:hover {
|
||
background: #2980b9;
|
||
}
|
||
button.secondary {
|
||
background: #95a5a6;
|
||
}
|
||
button.secondary:hover {
|
||
background: #7f8c8d;
|
||
}
|
||
button:disabled {
|
||
opacity: .5;
|
||
cursor: not-allowed;
|
||
}
|
||
.sub-nav {
|
||
display: flex;
|
||
gap: 12px;
|
||
justify-content: center;
|
||
margin: 0 0 8px;
|
||
}
|
||
.legend {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 18px;
|
||
justify-content: center;
|
||
margin: 8px 0 4px;
|
||
font-size: 14px;
|
||
color: #34495e;
|
||
}
|
||
.legend span {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.swatch {
|
||
display: inline-block;
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 4px;
|
||
border: 1px solid rgba(0,0,0,.15);
|
||
}
|
||
.info {
|
||
text-align: center;
|
||
min-height: 24px;
|
||
font-size: 16px;
|
||
color: #34495e;
|
||
margin: 10px 0 4px;
|
||
}
|
||
.equation {
|
||
text-align: center;
|
||
font-size: 24px;
|
||
font-family: 'Cascadia Mono', Consolas, monospace;
|
||
color: #c0392b;
|
||
margin: 6px 0 10px;
|
||
}
|
||
.eq-note {
|
||
font-size: 14px;
|
||
color: #7f8c8d;
|
||
}
|
||
.canvas-wrap {
|
||
text-align: center;
|
||
}
|
||
canvas {
|
||
max-width: 100%;
|
||
height: auto;
|
||
border: 1px solid #e8ecf0;
|
||
border-radius: 12px;
|
||
background: #fbfcfd;
|
||
}
|
||
.note {
|
||
text-align: center;
|
||
color: #95a5a6;
|
||
font-size: 13px;
|
||
margin-top: 8px;
|
||
}
|
||
.result {
|
||
text-align: center;
|
||
font-size: 22px;
|
||
color: #27ae60;
|
||
margin: 12px 0 6px;
|
||
font-weight: bold;
|
||
}
|
||
.steps {
|
||
position: relative;
|
||
background: #fafbfc;
|
||
border: 1px solid #eef1f4;
|
||
border-radius: 10px;
|
||
padding: 10px 12px;
|
||
margin-top: 14px;
|
||
max-height: 220px;
|
||
overflow-y: auto;
|
||
font-size: 15px;
|
||
line-height: 1.7;
|
||
}
|
||
.step {
|
||
padding: 5px 10px;
|
||
border-left: 4px solid #dfe6ea;
|
||
border-radius: 0 6px 6px 0;
|
||
margin: 5px 0;
|
||
background: #fff;
|
||
}
|
||
.step.active {
|
||
background: #eef6fd;
|
||
border-left-color: #3498db;
|
||
}
|
||
.step.swap {
|
||
color: #7f8c8d;
|
||
border-left-color: #f39c12;
|
||
background: #fef9f2;
|
||
}
|
||
.step-eq {
|
||
color: #7f8c8d;
|
||
font-family: monospace;
|
||
font-size: 13px;
|
||
margin-left: 12px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="container">
|
||
<h1>欧几里得算法图解</h1>
|
||
<p class="subtitle">核心公式:<code>gcd(a, b) = gcd(b, a mod b)</code></p>
|
||
|
||
<div class="controls">
|
||
<label>a = <input id="num1" type="number" min="1" step="1" value="24"></label>
|
||
<label>b = <input id="num2" type="number" min="1" step="1" value="18"></label>
|
||
<button onclick="start()">开始演示</button>
|
||
<button id="autoBtn" class="secondary" onclick="toggleAuto()">自动演示</button>
|
||
<button class="secondary" onclick="randomExample()">随机示例</button>
|
||
</div>
|
||
|
||
<div class="sub-nav">
|
||
<button id="prevBtn" onclick="prev()">◀ 上一步</button>
|
||
<button id="nextBtn" onclick="next()">下一步 ▶</button>
|
||
</div>
|
||
|
||
<div class="legend">
|
||
<span><span class="swatch" style="background:rgba(52,152,219,.6);"></span> 边长为当前较小数的正方形,共 q 个</span>
|
||
<span><span class="swatch" style="background:rgba(243,156,18,.75);"></span> 余数带,旋转后成为下一步的矩形</span>
|
||
</div>
|
||
|
||
<div id="info" class="info"></div>
|
||
<div id="equation" class="equation"></div>
|
||
<div class="canvas-wrap">
|
||
<canvas id="canvas" width="760" height="460"></canvas>
|
||
</div>
|
||
|
||
<div class="note">示意图按同一比例缩放;橙色余数条旋转 90° 后就是下一步要切的矩形。</div>
|
||
<div id="result" class="result" style="display:none;"></div>
|
||
|
||
<div id="steps" class="steps"></div>
|
||
</div>
|
||
|
||
<script>
|
||
const canvas = document.getElementById('canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
let steps = [];
|
||
let current = 0;
|
||
let timer = null;
|
||
let originalA = 0;
|
||
let originalB = 0;
|
||
|
||
// 生成欧几里得算法的每一步
|
||
function computeSteps(a, b) {
|
||
let m = Math.max(a, b);
|
||
let n = Math.min(a, b);
|
||
const arr = [];
|
||
while (n > 0) {
|
||
const q = Math.floor(m / n);
|
||
const r = m % n;
|
||
arr.push({ m, n, q, r });
|
||
m = n;
|
||
n = r;
|
||
}
|
||
return arr;
|
||
}
|
||
|
||
function stopAuto() {
|
||
if (timer) {
|
||
clearInterval(timer);
|
||
timer = null;
|
||
document.getElementById('autoBtn').textContent = '自动演示';
|
||
}
|
||
}
|
||
|
||
function start() {
|
||
const a = parseInt(document.getElementById('num1').value, 10);
|
||
const b = parseInt(document.getElementById('num2').value, 10);
|
||
if (!Number.isInteger(a) || !Number.isInteger(b) || a <= 0 || b <= 0) {
|
||
alert('请输入正整数!');
|
||
return;
|
||
}
|
||
originalA = a;
|
||
originalB = b;
|
||
steps = computeSteps(a, b);
|
||
current = 0;
|
||
stopAuto();
|
||
update();
|
||
}
|
||
|
||
function next() {
|
||
stopAuto();
|
||
if (current < steps.length - 1) {
|
||
current++;
|
||
update();
|
||
}
|
||
}
|
||
|
||
function prev() {
|
||
stopAuto();
|
||
if (current > 0) {
|
||
current--;
|
||
update();
|
||
}
|
||
}
|
||
|
||
function toggleAuto() {
|
||
if (timer) {
|
||
stopAuto();
|
||
return;
|
||
}
|
||
if (steps.length === 0) start();
|
||
if (steps.length === 0) return;
|
||
|
||
if (current >= steps.length - 1) {
|
||
current = 0;
|
||
update();
|
||
}
|
||
|
||
document.getElementById('autoBtn').textContent = '暂停';
|
||
timer = setInterval(() => {
|
||
if (current < steps.length - 1) {
|
||
current++;
|
||
update();
|
||
} else {
|
||
stopAuto();
|
||
}
|
||
}, 2000);
|
||
}
|
||
|
||
function update() {
|
||
if (steps.length === 0) return;
|
||
|
||
const step = steps[current];
|
||
const total = steps.length;
|
||
|
||
drawStep(step);
|
||
|
||
const info = document.getElementById('info');
|
||
const equation = document.getElementById('equation');
|
||
const result = document.getElementById('result');
|
||
|
||
if (step.r === 0) {
|
||
info.innerHTML = `第 ${current + 1} / ${total} 步:余数为 0,图中已经全是边长为 <b>${step.n}</b> 的正方形。`;
|
||
equation.innerHTML = `gcd(${step.m}, ${step.n}) = <b>${step.n}</b> <span class="eq-note">(${step.m} = ${step.q} × ${step.n} + 0)</span>`;
|
||
result.innerHTML = `✅ gcd(${originalA}, ${originalB}) = <b>${step.n}</b>`;
|
||
result.style.display = 'block';
|
||
} else {
|
||
info.innerHTML = `第 ${current + 1} / ${total} 步:从 ${step.m} × ${step.n} 的矩形中,最多切出 <b>${step.q}</b> 个边长为 <b>${step.n}</b> 的正方形,剩余 <b>${step.r}</b> × ${step.n} 的橙色余条。`;
|
||
equation.innerHTML = `gcd(${step.m}, ${step.n}) = gcd(${step.n}, ${step.r}) <span class="eq-note">(${step.m} = ${step.q} × ${step.n} + ${step.r})</span>`;
|
||
result.style.display = 'none';
|
||
}
|
||
|
||
document.getElementById('prevBtn').disabled = (current === 0);
|
||
document.getElementById('nextBtn').disabled = (current === total - 1);
|
||
|
||
renderSteps();
|
||
}
|
||
|
||
function renderSteps() {
|
||
const stepsDiv = document.getElementById('steps');
|
||
let html = '';
|
||
|
||
// 如果输入时 a < b,为了画成“较宽矩形”,先交换说明
|
||
if (originalA && originalB && originalA !== originalB && originalA < originalB) {
|
||
html += `<div class="step swap">交换:gcd(${originalA}, ${originalB}) = gcd(${originalB}, ${originalA})(方便画图)</div>`;
|
||
}
|
||
|
||
steps.forEach((s, i) => {
|
||
const cls = 'step' + (i === current ? ' active' : '');
|
||
if (s.r === 0) {
|
||
html += `<div class="${cls}"><b>第${i + 1}步</b>:gcd(${s.m}, ${s.n}) = ${s.n}<span class="step-eq">${s.m} = ${s.q} × ${s.n} + 0</span></div>`;
|
||
} else {
|
||
html += `<div class="${cls}"><b>第${i + 1}步</b>:gcd(${s.m}, ${s.n}) = gcd(${s.n}, ${s.r})<span class="step-eq">${s.m} = ${s.q} × ${s.n} + ${s.r}</span></div>`;
|
||
}
|
||
});
|
||
|
||
stepsDiv.innerHTML = html;
|
||
|
||
const active = stepsDiv.querySelector('.active');
|
||
if (active) {
|
||
const top = active.offsetTop;
|
||
const bottom = top + active.offsetHeight;
|
||
if (top < stepsDiv.scrollTop || bottom > stepsDiv.scrollTop + stepsDiv.clientHeight) {
|
||
stepsDiv.scrollTop = top - (stepsDiv.clientHeight - active.offsetHeight) / 2;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 绘制当前步骤的矩形图解
|
||
function drawStep(step) {
|
||
const { m, n, q, r } = step;
|
||
const W = canvas.width;
|
||
const H = canvas.height;
|
||
|
||
ctx.clearRect(0, 0, W, H);
|
||
|
||
const left = 70, right = 50, top = 20, bottom = 55;
|
||
const availW = W - left - right;
|
||
const availH = H - top - bottom;
|
||
const scale = Math.min(availW / m, availH / n);
|
||
|
||
const rectW = m * scale;
|
||
const rectH = n * scale;
|
||
const x = (W - rectW) / 2;
|
||
const y = (H - rectH) / 2;
|
||
|
||
ctx.save();
|
||
|
||
// 外矩形背景
|
||
ctx.fillStyle = '#f4f7f9';
|
||
ctx.strokeStyle = '#2c3e50';
|
||
ctx.lineWidth = 2;
|
||
ctx.fillRect(x, y, rectW, rectH);
|
||
ctx.strokeRect(x, y, rectW, rectH);
|
||
|
||
const squareSize = n * scale;
|
||
const maxDraw = 60;
|
||
|
||
// 绘制蓝色正方形
|
||
if (q <= maxDraw) {
|
||
ctx.fillStyle = 'rgba(52,152,219,0.6)';
|
||
ctx.strokeStyle = 'rgba(46,134,193,0.8)';
|
||
ctx.lineWidth = 1;
|
||
for (let i = 0; i < q; i++) {
|
||
ctx.fillRect(x + i * squareSize, y, squareSize, squareSize);
|
||
ctx.strokeRect(x + i * squareSize, y, squareSize, squareSize);
|
||
}
|
||
} else {
|
||
// 如果方块太多,只画两端的一部分,中间用文字表示
|
||
const leftCount = 15;
|
||
const rightCount = 15;
|
||
|
||
ctx.fillStyle = 'rgba(52,152,219,0.6)';
|
||
ctx.strokeStyle = 'rgba(46,134,193,0.8)';
|
||
ctx.lineWidth = 1;
|
||
|
||
for (let i = 0; i < leftCount; i++) {
|
||
ctx.fillRect(x + i * squareSize, y, squareSize, squareSize);
|
||
ctx.strokeRect(x + i * squareSize, y, squareSize, squareSize);
|
||
}
|
||
for (let i = q - rightCount; i < q; i++) {
|
||
ctx.fillRect(x + i * squareSize, y, squareSize, squareSize);
|
||
ctx.strokeRect(x + i * squareSize, y, squareSize, squareSize);
|
||
}
|
||
|
||
const midStart = x + leftCount * squareSize;
|
||
const midWidth = (q - leftCount - rightCount) * squareSize;
|
||
ctx.fillStyle = 'rgba(52,152,219,0.2)';
|
||
ctx.fillRect(midStart, y, midWidth, rectH);
|
||
ctx.fillStyle = '#2c3e50';
|
||
ctx.font = '14px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(`共 ${q} 个`, x + (q / 2) * squareSize, y + rectH / 2);
|
||
}
|
||
|
||
// 绘制橙色余数带
|
||
if (r > 0) {
|
||
const rx = x + q * squareSize;
|
||
const rw = r * scale;
|
||
|
||
ctx.fillStyle = 'rgba(243,156,18,0.75)';
|
||
ctx.strokeStyle = 'rgba(230,126,34,0.9)';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.fillRect(rx, y, rw, rectH);
|
||
ctx.strokeRect(rx, y, rw, rectH);
|
||
|
||
if (rw > 35 && rectH > 35) {
|
||
ctx.fillStyle = '#6e4a00';
|
||
ctx.font = 'bold 14px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(`r=${r}`, rx + rw / 2, y + rectH / 2);
|
||
}
|
||
}
|
||
|
||
// 重新描边,让外框更清晰
|
||
ctx.strokeStyle = '#2c3e50';
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(x, y, rectW, rectH);
|
||
|
||
// 尺寸标注
|
||
ctx.fillStyle = '#2c3e50';
|
||
ctx.font = 'bold 15px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'alphabetic';
|
||
ctx.fillText(`m = ${m}`, x + rectW / 2, y + rectH + 28);
|
||
|
||
ctx.save();
|
||
ctx.translate(x - 28, y + rectH / 2);
|
||
ctx.rotate(-Math.PI / 2);
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(`n = ${n}`, 0, 0);
|
||
ctx.restore();
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
// 随机示例
|
||
function randomExample() {
|
||
const examples = [
|
||
[1071, 462],
|
||
[24, 18],
|
||
[180, 48],
|
||
[98, 56],
|
||
[55, 34],
|
||
[240, 126],
|
||
[13, 7],
|
||
[625, 125],
|
||
[97, 13],
|
||
[31415, 27182],
|
||
[64, 36],
|
||
[119, 34]
|
||
];
|
||
const [a, b] = examples[Math.floor(Math.random() * examples.length)];
|
||
document.getElementById('num1').value = a;
|
||
document.getElementById('num2').value = b;
|
||
start();
|
||
}
|
||
|
||
// 页面加载后默认演示 24 和 18
|
||
window.addEventListener('DOMContentLoaded', () => {
|
||
document.getElementById('num1').value = 24;
|
||
document.getElementById('num2').value = 18;
|
||
start();
|
||
});
|
||
</script>
|
||
|
||
</body>
|
||
</html> |