console
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>希沃白板|互动旋转几何</title>
<style>
*{box-sizing:border-box}
body{
margin:0;background:#f3f6fb;color:#1e293b;
font-family:"Microsoft YaHei","PingFang SC",sans-serif;
touch-action:manipulation;
}
main{max-width:1100px;margin:auto;padding:18px}
h1{font-size:26px;margin:0 0 8px}
h2{font-size:19px;margin:0 0 12px}
p{line-height:1.65}
.muted{color:#64748b;font-size:14px}
.panel{
background:white;border:1px solid #dbe3ee;
border-radius:14px;padding:15px;margin-bottom:14px;
}
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
button{
font:inherit;font-size:16px;padding:11px 17px;
border:1px solid #cbd5e1;border-radius:10px;
background:white;color:#1e293b;cursor:pointer;
touch-action:manipulation;user-select:none;
}
button:active{transform:scale(.98)}
button.active{background:#dbeafe;border:2px solid #2563eb;padding:10px 16px}
button.primary{background:#2563eb;color:white;border-color:#2563eb}
button:disabled{opacity:.45;cursor:default}
#status{
font-size:17px;font-weight:700;line-height:1.65;
background:#eff6ff;color:#1d4ed8;
border-radius:10px;padding:12px;margin-top:12px;
}
#status.ok{background:#ecfdf5;color:#047857}
.stage{
border:1px solid #dbe3ee;border-radius:12px;
overflow:hidden;background:white;
}
svg{
display:block;width:100%;height:auto;
touch-action:manipulation;
-webkit-user-select:none;user-select:none;
}
svg .hit-area{cursor:pointer;pointer-events:all}
svg .triangle-label{cursor:pointer;pointer-events:all}
svg .vertex-hit{cursor:pointer;pointer-events:all}
.legend{
display:flex;gap:12px;flex-wrap:wrap;
padding:11px;font-size:13px;color:#64748b;
border-top:1px solid #e2e8f0;
}
.key{display:inline-block;width:22px;border-top:3px solid;margin-right:5px}
.info{
padding:13px;background:#f8fafc;border:1px solid #e2e8f0;
border-radius:10px;margin-top:12px;line-height:1.9;
}
.blue{color:#2563eb;font-weight:700}
.orange{color:#ea580c;font-weight:700}
@media(max-width:600px){
main{padding:9px}
h1{font-size:22px}
.panel{padding:10px}
button{padding:10px 12px}
}
</style>
</head>
<body>
<main>
<h1>等边三角形:点击选择,旋转演示</h1>
<p class="muted">
操作顺序:①选择内部点或外部点 → ②点一个小三角形
→ ③点旋转顶点。适用于触摸屏和希沃白板笔。
</p>
<section class="panel">
<h2>第一步:选择 P 的位置</h2>
<div class="row">
<button class="active" data-position="inside">P 在内部</button>
<button data-position="outside">P 在外部</button>
</div>
</section>
<section class="panel">
<h2>第二步:直接点击图中的小三角形</h2>
<p class="muted">
点 △APB、△BPC 或 △CPA 的内部,也可以点击图中的三角形标签。
选中后会高亮显示。
</p>
<div class="stage">
<svg id="board" viewBox="0 0 900 690"
aria-label="点击小三角形,再点击旋转中心"></svg>
<div class="legend">
<span><i class="key" style="border-color:#64748b"></i>原图形</span>
<span><i class="key" style="border-color:#2563eb"></i>选中的小三角形</span>
<span><i class="key" style="border-color:#ea580c;border-top-style:dashed"></i>旋转后的图形</span>
<span style="color:#dc2626">● 原点 P</span>
<span style="color:#ea580c">● 旋转后的点 P′</span>
</div>
</div>
<div id="status" aria-live="polite">
请先点击图中的一个小三角形。
</div>
</section>
<section class="panel">
<h2>第三步:点击旋转顶点</h2>
<p id="centerHelp" class="muted">
选择小三角形后,可以点击该三角形包含的 A、B 或 C 中的两个顶点。
点 P 不是旋转中心。
</p>
<div class="row">
<button id="pause" class="primary" disabled>暂停动画</button>
<button id="replay" disabled>重新旋转 ↻</button>
<button id="clear">重新选择</button>
</div>
<div class="info">
<div>当前小三角形:<strong id="chosenTriangle">尚未选择</strong></div>
<div>旋转中心:<strong id="chosenCenter">尚未选择</strong></div>
<div>旋转方向:<strong id="chosenDirection">—</strong></div>
<div class="blue" id="equalities">对应线段关系将在这里显示。</div>
<div id="explain" class="muted">选好三角形和顶点后,自动播放旋转动画。</div>
</div>
</section>
</main>
<script>
(() => {
"use strict";
const $ = id => document.getElementById(id);
const svg = $("board");
const side = 460;
const h = side * Math.sqrt(3) / 2;
const V = {
A: {x:430,y:95},
B: {x:200,y:95+h},
C: {x:660,y:95+h}
};
const P = {
inside: {x:430,y:290},
outside: {x:730,y:290}
};
const triangles = {
ABP: {
names:["A","B","P"],
centers:{
A:{from:"B",to:"C",angle:-60,equality:"AP = AP′,BP = CP′"},
B:{from:"A",to:"C",angle:60,equality:"BP = BP′,AP = CP′"}
}
},
BPC: {
names:["B","P","C"],
centers:{
B:{from:"C",to:"A",angle:-60,equality:"BP = BP′,CP = AP′"},
C:{from:"B",to:"A",angle:60,equality:"CP = CP′,BP = AP′"}
}
},
CPA: {
names:["C","P","A"],
centers:{
C:{from:"A",to:"B",angle:-60,equality:"CP = CP′,AP = BP′"},
A:{from:"C",to:"B",angle:60,equality:"AP = AP′,CP = BP′"}
}
}
};
const triangleColors = {
ABP:"#60a5fa",
BPC:"#34d399",
CPA:"#c4b5fd"
};
let position = "inside";
let chosen = null;
let center = null;
let raf = 0;
let progress = 0;
let angleNow = 0;
let startTime = 0;
let paused = false;
let finished = false;
const duration = 1600;
const fmt = n => Number(n.toFixed(2));
const xy = p => `${fmt(p.x)},${fmt(p.y)}`;
function rotatePoint(p,o,degrees){
const r = degrees*Math.PI/180;
const x = p.x-o.x;
const y = p.y-o.y;
return {
x:o.x+x*Math.cos(r)-y*Math.sin(r),
y:o.y+x*Math.sin(r)+y*Math.cos(r)
};
}
function poly(points,fill,stroke,width=2,extra=""){
return `<polygon points="${points.map(xy).join(" ")}"
fill="${fill}" stroke="${stroke}" stroke-width="${width}"
stroke-linejoin="round" ${extra}/>`;
}
function line(a,b,color,width=2,dash=""){
return `<line x1="${fmt(a.x)}" y1="${fmt(a.y)}"
x2="${fmt(b.x)}" y2="${fmt(b.y)}"
stroke="${color}" stroke-width="${width}"
${dash?`stroke-dasharray="${dash}"`:""}/>`;
}
function dot(p,r,color,extra=""){
return `<circle cx="${fmt(p.x)}" cy="${fmt(p.y)}"
r="${r}" fill="${color}" ${extra}/>`;
}
function text(p,s,color="#1e293b",size=20,anchor="middle",extra=""){
return `<text x="${fmt(p.x)}" y="${fmt(p.y)}"
fill="${color}" font-size="${size}" font-weight="700"
text-anchor="${anchor}" paint-order="stroke"
stroke="white" stroke-width="4" stroke-linejoin="round"
${extra}>${s}</text>`;
}
function vertexLabel(name,p){
const offsets={
A:{x:0,y:-19,anchor:"middle"},
B:{x:-14,y:24,anchor:"end"},
C:{x:15,y:24,anchor:"start"}
};
const o=offsets[name];
return text(
{x:p.x+o.x,y:p.y+o.y},
name,"#1e293b",22,o.anchor
);
}
function arcMarkup(o,p,angle){
const p2=rotatePoint(p,o,angle);
const radius=38;
const a1=Math.atan2(p.y-o.y,p.x-o.x);
const a2=Math.atan2(p2.y-o.y,p2.x-o.x);
const s={
x:o.x+radius*Math.cos(a1),
y:o.y+radius*Math.sin(a1)
};
const e={
x:o.x+radius*Math.cos(a2),
y:o.y+radius*Math.sin(a2)
};
const mid=a1+angle*Math.PI/360;
const labelP={
x:o.x+54*Math.cos(mid),
y:o.y+54*Math.sin(mid)
};
return `
<path d="M ${xy(s)} A ${radius} ${radius} 0 0
${angle>0?1:0} ${xy(e)}"
fill="none" stroke="#0f766e" stroke-width="3"
marker-end="url(#arrow)"/>
${text(labelP,"60°","#0f766e",15)}
`;
}
function cancelAnimation(){
if(raf) cancelAnimationFrame(raf);
raf=0;
progress=0;
angleNow=0;
paused=false;
finished=false;
}
function resetAll(){
cancelAnimation();
chosen=null;
center=null;
render();
setStatus("请点击图中的一个小三角形。");
}
function setStatus(message,success=false){
$("status").textContent=message;
$("status").className=success?"ok":"";
}
function centroid(points){
return {
x:points.reduce((sum,p)=>sum+p.x,0)/points.length,
y:points.reduce((sum,p)=>sum+p.y,0)/points.length
};
}
function triangleLabel(key,pts){
const c=centroid(pts);
const w=76, h=31;
const selected=chosen===key;
return `
<g class="triangle-label" data-triangle="${key}"
role="button" tabindex="0"
aria-label="选择三角形 ${key}">
<rect x="${fmt(c.x-w/2)}" y="${fmt(c.y-h/2)}"
width="${w}" height="${h}" rx="9"
fill="${selected?"#2563eb":"white"}"
fill-opacity="${selected?1:0.94}"
stroke="${selected?"#1d4ed8":"#94a3b8"}"
stroke-width="${selected?3:1.5}"/>
<text x="${fmt(c.x)}" y="${fmt(c.y+6)}"
font-size="17" font-weight="700"
text-anchor="middle"
fill="${selected?"white":"#334155"}"
stroke="none">△${key}</text>
</g>
`;
}
function vertexHandle(name,p){
const allowed=chosen && triangles[chosen].centers[name];
const isCenter=center===name;
const active=allowed && !center;
const fill=isCenter?"#0f766e":active?"#10b981":"#64748b";
const opacity=chosen && !allowed?0.65:1;
return `
<g class="vertex-hit" data-vertex="${name}"
role="button" tabindex="0"
aria-label="选择 ${name} 为旋转中心"
style="cursor:pointer;opacity:${opacity}">
<circle cx="${p.x}" cy="${p.y}" r="25"
fill="transparent"/>
<circle cx="${p.x}" cy="${p.y}" r="${isCenter?11:active?10:7}"
fill="${fill}" stroke="white" stroke-width="2"/>
${active?`<circle cx="${p.x}" cy="${p.y}" r="17"
fill="none" stroke="#10b981" stroke-width="2"
stroke-dasharray="4 3"/>`:""}
${vertexLabel(name,p)}
</g>
`;
}
function render(){
const p=P[position];
const keys=["ABP","BPC","CPA"];
let out=`
<defs>
<marker id="arrow" markerWidth="9" markerHeight="9"
refX="7" refY="4" orient="auto"
markerUnits="userSpaceOnUse">
<path d="M0,0 L8,4 L0,8 Z" fill="#0f766e"/>
</marker>
</defs>
<rect width="900" height="690" fill="white"/>
<text x="22" y="30" font-size="18" font-weight="700" fill="#334155">
${position==="inside"?"P 在 △ABC 内部":"P 在 △ABC 外部"}
</text>
<text x="22" y="55" font-size="14" fill="#64748b">
${chosen?"已选 △"+chosen+":请点击高亮顶点作为旋转中心":"点击一个小三角形开始"}
</text>
${poly([V.A,V.B,V.C],"#f8fafc","#64748b",2.5)}
${line(V.A,p,"#94a3b8",2)}
${line(V.B,p,"#94a3b8",2)}
${line(V.C,p,"#94a3b8",2)}
`;
// 原始的三个小三角形。
for(const key of keys){
const pts=triangles[key].names.map(n=>n==="P"?p:V[n]);
const isSelected=chosen===key;
out+=poly(
pts,
triangleColors[key],
isSelected?"#2563eb":"#64748b",
isSelected?3.5:1.7,
`fill-opacity="${isSelected?0.48:0.20}"`
);
}
// 透明的可点击区域。外部点时三角形可能重叠,
// 因此也提供每个三角形的独立标签作为精准点击目标。
for(const key of keys){
const pts=triangles[key].names.map(n=>n==="P"?p:V[n]);
out+=`
<polygon class="hit-area" data-triangle="${key}"
points="${pts.map(xy).join(" ")}"
fill="transparent" stroke="transparent"
stroke-width="8"/>
`;
}
// 标签位于可点击区域上方,方便希沃笔精确选取。
for(const key of keys){
const pts=triangles[key].names.map(n=>n==="P"?p:V[n]);
out+=triangleLabel(key,pts);
}
// 选定中心后,绘制旋转弧线和旋转后的三角形。
if(chosen && center){
const cfg=triangles[chosen].centers[center];
const o=V[center];
const f=V[cfg.from];
out+=arcMarkup(o,p,cfg.angle);
out+=`
<g id="rotatedGroup"
transform="rotate(${angleNow} ${o.x} ${o.y})">
${poly([o,f,p],"#fb923c","#ea580c",3,"stroke-dasharray='9 6' fill-opacity='0.20'")}
</g>
<circle id="primeDot" cx="${p.x}" cy="${p.y}" r="7"
fill="#ea580c" stroke="white" stroke-width="2"
visibility="hidden"/>
<text id="primeLabel" x="${p.x+12}" y="${p.y-12}"
font-size="21" font-weight="700" fill="#c2410c"
paint-order="stroke" stroke="white" stroke-width="4"
visibility="hidden">P′</text>
`;
}
// 原点 P 保持可见。
out+=dot(p,7,"#dc2626",'stroke="white" stroke-width="2"');
out+=text({x:p.x+13,y:p.y-13},"P","#dc2626",21,"start");
// 顶点交互层放在最上面,便于直接点击。
for(const name of ["A","B","C"]){
out+=vertexHandle(name,V[name]);
}
out+=`
<text x="22" y="650" font-size="15" fill="#64748b">
${chosen && !center
?"绿色虚线圆圈:可选择的旋转中心"
:center
?"绿色顶点:"+center+"(旋转中心保持不动)"
:"A、B、C 为原三角形的三个顶点"}
</text>
`;
svg.innerHTML=out;
// 绑定三角形点击事件。
svg.querySelectorAll("[data-triangle]").forEach(el=>{
el.addEventListener("click",e=>{
e.stopPropagation();
selectTriangle(el.dataset.triangle);
});
el.addEventListener("keydown",e=>{
if(e.key==="Enter"||e.key===" "){
e.preventDefault();
e.stopPropagation();
selectTriangle(el.dataset.triangle);
}
});
});
// 绑定顶点点击事件。
svg.querySelectorAll("[data-vertex]").forEach(el=>{
el.addEventListener("click",e=>{
e.stopPropagation();
selectCenter(el.dataset.vertex);
});
el.addEventListener("keydown",e=>{
if(e.key==="Enter"||e.key===" "){
e.preventDefault();
e.stopPropagation();
selectCenter(el.dataset.vertex);
}
});
});
updateInfo();
}
function selectTriangle(key){
cancelAnimation();
chosen=key;
center=null;
render();
const allowed=Object.keys(triangles[key].centers).join("、");
setStatus(`已选中 △${key}。现在请直接点击顶点 ${allowed} 中的一个作为旋转中心。`);
}
function selectCenter(name){
if(!chosen){
setStatus("请先点击一个小三角形,再选择旋转中心。");
return;
}
const cfg=triangles[chosen].centers[name];
if(!cfg){
const allowed=Object.keys(triangles[chosen].centers).join("、");
setStatus(`△${chosen} 只能选择 ${allowed} 作为旋转中心,请点击绿色标记的顶点。`);
return;
}
cancelAnimation();
center=name;
render();
setStatus(
`开始旋转:△${chosen} 绕 ${center} ${cfg.angle>0?"顺时针":"逆时针"}旋转 60°,${cfg.from} 将与 ${cfg.to} 重合。`
);
startAnimation();
}
function updateInfo(){
$("chosenTriangle").textContent=chosen?"△"+chosen:"尚未选择";
$("chosenCenter").textContent=center||"尚未选择";
if(chosen && center){
const cfg=triangles[chosen].centers[center];
$("chosenDirection").textContent=
`${cfg.angle>0?"顺时针":"逆时针"} 60°`;
$("equalities").textContent=cfg.equality;
$("explain").textContent=
`旋转前的顶点 ${cfg.from} 与旋转后的顶点 ${cfg.to} 重合;中心 ${center} 不动,P 变为 P′。`;
}else if(chosen){
$("chosenDirection").textContent="等待选择";
$("equalities").textContent="请先选旋转中心。";
$("explain").textContent=
`△${chosen} 可选的旋转中心为 ${Object.keys(triangles[chosen].centers).join("、")}。`;
}else{
$("chosenDirection").textContent="—";
$("equalities").textContent="对应线段关系将在这里显示。";
$("explain").textContent="选好三角形和顶点后,自动播放旋转动画。";
}
$("centerHelp").textContent=chosen
?`已选 △${chosen}。请点击绿色标记的 ${Object.keys(triangles[chosen].centers).join(" 或 ")}。`
:"选择小三角形后,可以点击该三角形包含的两个原顶点作为旋转中心。点 P 不是旋转中心。";
$("pause").disabled=!center;
$("replay").disabled=!center;
}
function updateAnimation(){
const cfg=triangles[chosen].centers[center];
const o=V[center];
const p=P[position];
const group=$("rotatedGroup");
const primeDot=$("primeDot");
const primeLabel=$("primeLabel");
if(!group||!primeDot||!primeLabel)return;
group.setAttribute(
"transform",`rotate(${angleNow} ${o.x} ${o.y})`
);
const q=rotatePoint(p,o,angleNow);
primeDot.setAttribute("cx",q.x);
primeDot.setAttribute("cy",q.y);
primeDot.setAttribute(
"visibility",progress>0.03||finished?"visible":"hidden"
);
const dx=q.x>790?-14:13;
primeLabel.setAttribute("x",q.x+dx);
primeLabel.setAttribute("y",q.y<75?q.y+24:q.y-13);
primeLabel.setAttribute(
"text-anchor",dx<0?"end":"start"
);
primeLabel.setAttribute(
"visibility",progress>0.03||finished?"visible":"hidden"
);
}
function startAnimation(){
if(!chosen||!center)return;
if(raf)cancelAnimationFrame(raf);
raf=0;
progress=0;
angleNow=0;
paused=false;
finished=false;
startTime=performance.now();
$("pause").textContent="暂停动画";
updateAnimation();
raf=requestAnimationFrame(tick);
}
function tick(now){
progress=Math.min(1,(now-startTime)/duration);
// 缓入缓出,旋转更容易在大屏幕上观察。
const eased=0.5-0.5*Math.cos(Math.PI*progress);
const cfg=triangles[chosen].centers[center];
angleNow=cfg.angle*eased;
updateAnimation();
if(progress<1){
raf=requestAnimationFrame(tick);
}else{
raf=0;
finished=true;
angleNow=cfg.angle;
updateAnimation();
$("pause").textContent="继续 / 重播";
setStatus(
`旋转完成!△${chosen} 已绕 ${center} 旋转 60°,${cfg.from} 与 ${cfg.to} 重合。`,
true
);
}
}
$("pause").addEventListener("click",()=>{
if(!center)return;
if(finished){
startAnimation();
return;
}
if(!paused){
paused=true;
if(raf)cancelAnimationFrame(raf);
raf=0;
$("pause").textContent="继续动画";
setStatus("动画已暂停。点击“继续动画”即可接着播放。");
}else{
paused=false;
startTime=performance.now()-progress*duration;
$("pause").textContent="暂停动画";
raf=requestAnimationFrame(tick);
}
});
$("replay").addEventListener("click",()=>{
if(center)startAnimation();
});
$("clear").addEventListener("click",resetAll);
document.querySelectorAll("[data-position]").forEach(btn=>{
btn.addEventListener("click",()=>{
position=btn.dataset.position;
document.querySelectorAll("[data-position]").forEach(b=>{
b.classList.toggle("active",b===btn);
});
resetAll();
});
});
render();
})();
</script>
</body>
</html>