feat: 新增思想实验室模块与配套功能

- 新增5个物理仿真实验室:黑洞模拟、日地轨道、3D太阳系、π推导、拉格朗日点
- 新增思想实验室后端路由与鉴权逻辑
- 首页新增思想实验室页签与前端渲染逻辑
- 修复中韩半导体监控标的配置错误
- 新增市场技能推送白名单过滤逻辑与单元测试
This commit is contained in:
yangxiangyuan
2026-08-06 13:39:48 +08:00
parent 6a7a2b089a
commit ea379116cc
19 changed files with 2831 additions and 17 deletions
@@ -0,0 +1,826 @@
<!-- ============================================================
思想实验室 · labs/pi_derivation · π 推导实验室
随机剖分采样三角形内角,用角度积分公式反推圆周率。
独立 lab:单文件自包含,不引用其他 lab 的任何资源。
服务端配置:src/server/thought_lab/labs/pi_derivation/config.json
============================================================ -->
<!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:#070a12; --panel:#0d1220; --panel2:#111a2e; --line:#1e2a44;
--cyan:#53d8ff; --green:#42e696; --orange:#ffb454; --pink:#ff6b9d;
--text:#dbe6ff; --dim:#7d8db1;
--mono:'Consolas','Cascadia Mono','JetBrains Mono',monospace;
}
*{margin:0;padding:0;box-sizing:border-box}
body{
background:radial-gradient(1200px 800px at 70% -10%, #101b36 0%, var(--bg) 55%);
color:var(--text); font-family:'Segoe UI','Microsoft YaHei',sans-serif;
min-height:100vh; padding:16px;
}
header{max-width:1440px;margin:0 auto 12px}
h1{font-size:21px;letter-spacing:1px}
h1 b{color:var(--cyan)}
.sub{color:var(--dim);font-size:12.5px;margin-top:4px;line-height:1.6}
main{max-width:1440px;margin:0 auto;display:flex;gap:14px;flex-wrap:wrap}
.panel{
background:linear-gradient(180deg,var(--panel2),var(--panel));
border:1px solid var(--line); border-radius:14px; padding:14px;
box-shadow:0 10px 30px rgba(0,0,0,.35);
}
#geoPanel{flex:1.25;min-width:580px}
#statsPanel{flex:1;min-width:440px}
.ptitle{
font-size:13px;color:var(--cyan);letter-spacing:3px;margin-bottom:10px;
display:flex;justify-content:space-between;align-items:center;
}
.ptitle small{color:var(--dim);letter-spacing:1px}
canvas{display:block;width:100%;border-radius:10px;background:#0a0f1c;border:1px solid var(--line)}
#geo{height:540px}
#hist{height:150px}
#spark{height:72px}
.controls{display:flex;gap:10px;align-items:center;margin-top:10px;flex-wrap:wrap}
button{
background:#16223c;border:1px solid #2b3f66;color:var(--text);
padding:7px 14px;border-radius:8px;cursor:pointer;font-size:13px;
}
button:hover{border-color:var(--cyan);color:var(--cyan)}
.speedbox{display:flex;align-items:center;gap:6px;color:var(--dim);font-size:12px}
input[type=range]{accent-color:var(--cyan);width:110px}
#statusLine{color:var(--dim);font-size:12px;margin-top:8px;font-family:var(--mono)}
/* ---- 统计面板 ---- */
.hero{background:#0a1122;border:1px solid var(--line);border-radius:10px;padding:12px 14px}
.hero .lab{font-size:12px;color:var(--dim);letter-spacing:2px}
.big{font-family:var(--mono);font-size:29px;margin-top:4px;letter-spacing:.5px}
.big .ok{color:var(--green)}
.big .bad{color:#ff5c7a}
.ref{font-family:var(--mono);font-size:13px;color:var(--dim);margin-top:4px}
.meta{display:flex;gap:14px;margin-top:8px;font-size:12px;font-family:var(--mono);flex-wrap:wrap}
.meta b{color:var(--cyan)}
.badge{padding:2px 10px;border-radius:20px;font-size:12px;font-weight:600}
.badge.ok{background:rgba(66,230,150,.15);color:var(--green);border:1px solid rgba(66,230,150,.4)}
.badge.no{background:rgba(255,180,84,.12);color:var(--orange);border:1px solid rgba(255,180,84,.35)}
.formula{
font-family:var(--mono);font-size:12.5px;color:#9fb4de;
background:#0a1122;border-left:3px solid var(--cyan);
padding:8px 12px;border-radius:6px;margin:10px 0;line-height:1.8;
}
.grid4{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px}
.card{background:#0a1122;border:1px solid var(--line);border-radius:8px;padding:8px 10px}
.card .k{font-size:11px;color:var(--dim)}
.card .v{font-family:var(--mono);font-size:17px;margin-top:2px}
.card .s{font-size:11px;color:var(--dim);font-family:var(--mono)}
.acc{color:var(--cyan)} .obt{color:var(--orange)} .grn{color:var(--green)}
.secTitle{font-size:12px;color:var(--dim);letter-spacing:2px;margin:10px 0 6px}
.mcRow{display:flex;gap:10px;align-items:stretch}
.mcRow .card{flex:1}
table.log{width:100%;border-collapse:collapse;font-family:var(--mono);font-size:11.5px;margin-top:6px}
table.log th{color:var(--dim);font-weight:400;text-align:left;padding:3px 6px;border-bottom:1px solid var(--line)}
table.log td{padding:3px 6px;border-bottom:1px solid #141d33;color:#c4d3f5}
.foot{color:#5a6a8f;font-size:11px;margin-top:10px;line-height:1.6}
</style>
</head>
<body>
<header>
<h1>π 推导实验室 <b>Random-Angle Derivation Lab</b></h1>
<div class="sub">
基础流程:正方形 → 随机三角剖分 → 随机取走一块 → 剩余面积守恒重拼矩形(循环)。
每刀切割角度完全随机;记录每个被取走三角形的三个内角,统计锐角/钝角三角形的角度分布,用角度积分公式 π̂ = 3·∫₀<sup>π</sup> θ·ρ(θ)dθ 反推 π。
</div>
</header>
<main>
<!-- ============ 左:几何动画视图 ============ -->
<section class="panel" id="geoPanel">
<div class="ptitle">◤ 几何动画视图 <small>Canvas 实时模拟</small></div>
<canvas id="geo"></canvas>
<div class="controls">
<button id="btnRun">⏸ 暂停</button>
<button id="btnFast">⚡ 后台快进 ×5000</button>
<button id="btnReset">↺ 重置统计</button>
<div class="speedbox">速度 <input type="range" id="speed" min="0.5" max="4" step="0.1" value="1"><span id="speedVal">1.0×</span></div>
</div>
<div id="statusLine">初始化…</div>
</section>
<!-- ============ 右:概率统计计算面板 ============ -->
<section class="panel" id="statsPanel">
<div class="ptitle">◤ 概率统计计算面板 <small>后台实时统计</small></div>
<div class="hero">
<div class="lab">角度积分反推 π̂(主估计器)</div>
<div class="big" id="piHat">—</div>
<div class="ref">理论 π&nbsp;&nbsp;= 3.141592653589793238…</div>
<div class="meta">
<span>绝对误差 <b id="piErr">—</b></span>
<span>稳定小数位 <b id="piDigits">—</b></span>
<span id="badge" class="badge no">目标 ≥ 10 位</span>
</div>
</div>
<div class="formula">
π̂ = (1/N)·Σₖ(αₖ+βₖ+γₖ) = 3·∫₀^π θ·ρ(θ)dθ&nbsp;&nbsp;(ρ 为记录角度的经验分布)<br>
π̂<sub>MC</sub> = 2 / E[sin θ<sub>cut</sub>]&nbsp;&nbsp;(均匀随机测量角,Buffon 型对照)
</div>
<div class="grid4">
<div class="card"><div class="k">取走三角形 N</div><div class="v" id="cN">0</div></div>
<div class="card"><div class="k">角度样本 3N</div><div class="v" id="cAng">0</div></div>
<div class="card"><div class="k acc">锐角三角形</div><div class="v acc" id="cAcute">0</div><div class="s" id="pAcute">P = —</div></div>
<div class="card"><div class="k obt">钝角三角形</div><div class="v obt" id="cObtuse">0</div><div class="s" id="pObtuse">P = —</div></div>
</div>
<div class="secTitle">角度分布直方图(0°–180°,按三角形类型堆叠)</div>
<canvas id="hist"></canvas>
<div class="secTitle">独立蒙特卡洛对照(慢收敛 · 1/√N)</div>
<div class="mcRow">
<div class="card">
<div class="k">π̂<sub>MC</sub> = 2/⟨sinθ⟩</div>
<div class="v grn" id="piBuffon">—</div>
<div class="s">误差 <span id="buffErr">—</span></div>
<div class="s">测量角样本 <span id="buffN">0</span></div>
</div>
<div class="card"><canvas id="spark" style="height:100%;border:none;background:transparent"></canvas></div>
<div class="card">
<div class="k">直方图数值积分 π̂<sub>h</sub>(粗)</div>
<div class="v obt" id="piHistVal">—</div>
<div class="s">分箱 5°,演示离散化误差</div>
</div>
</div>
<div class="secTitle">最近取走样本(每行验证 α+β+γ = π)</div>
<table class="log">
<thead><tr><th>#</th><th>α</th><th>β</th><th>γ</th><th>类型</th><th>Σ (rad)</th></tr></thead>
<tbody id="logBody"></tbody>
</table>
<div class="foot">
原理:欧氏几何中任意三角形内角和恒等于 π。实验室用完全随机的切割反复生成三角形并采样其内角,
以经验分布 ρ(θ) 的积分 3·E[θ] 重现该恒等式——因此主估计器在采样下即确定性地稳定到机器精度(&gt;10 位小数);
右侧 Buffon 型估计器作为对照,展示普通蒙特卡洛只能以 1/√N 速度缓慢收敛。
</div>
</section>
</main>
<script>
'use strict';
/* =========================================================
* π 推导实验室 —— 随机角度统计推导圆周率
* 流程:矩形 → 随机点剖分(切割角随机) → 随机取走一块(记录内角)
* → 剩余块重拼为等面积新矩形 → 循环
* ========================================================= */
// ---------- DOM ----------
const geo = document.getElementById('geo'), g = geo.getContext('2d');
const histCv = document.getElementById('hist'), hc = histCv.getContext('2d');
const sparkCv = document.getElementById('spark'), sc = sparkCv.getContext('2d');
const $ = id => document.getElementById(id);
const el = {
piHat:$('piHat'), err:$('piErr'), digits:$('piDigits'), badge:$('badge'),
n:$('cN'), ang:$('cAng'), acute:$('cAcute'), pacute:$('pAcute'),
obtuse:$('cObtuse'), pobtuse:$('pObtuse'),
p2:$('piBuffon'), p2e:$('buffErr'), cutn:$('buffN'), ph:$('piHistVal'),
log:$('logBody'), status:$('statusLine'),
btnRun:$('btnRun'), btnFast:$('btnFast'), btnReset:$('btnReset'),
speed:$('speed'), speedVal:$('speedVal')
};
// ---------- 常量 ----------
const BINS = 36; // 角度直方图分箱数(0~180°,每箱 5°)
const DUR = {cut:1.15, flash:0.4, remove:1.35, fly:1.2, merge:0.8, settle:0.4, reset:0.85};
const PHASE_CN = {
idle:'等待', cut:'随机切割(切割角完全随机)', flash:'剖分完成',
remove:'随机取走一块 · 记录三个内角', fly:'剩余块重拼 → 新矩形',
merge:'面积守恒合并', settle:'本轮完成', reset:'面积过小 → 重置新正方形'
};
const MONO = "Consolas,'Cascadia Mono',monospace";
// ---------- 工具函数 ----------
const rand = (a,b)=>a+(b-a)*Math.random();
const clamp=(v,a,b)=>v<a?a:(v>b?b:v);
const lerp =(a,b,t)=>a+(b-a)*t;
const easeIO=t=>t<.5?4*t*t*t:1-Math.pow(-2*t+2,3)/2;
const sub=(p,q)=>({x:p.x-q.x,y:p.y-q.y});
const add=(p,q)=>({x:p.x+q.x,y:p.y+q.y});
const mul=(p,s)=>({x:p.x*s,y:p.y*s});
const dot=(p,q)=>p.x*q.x+p.y*q.y;
const len=p=>Math.hypot(p.x,p.y);
const dist=(p,q)=>len(sub(p,q));
const norm=p=>{const l=len(p)||1;return{x:p.x/l,y:p.y/l};};
const rotVec=(v,a)=>({x:v.x*Math.cos(a)-v.y*Math.sin(a), y:v.x*Math.sin(a)+v.y*Math.cos(a)});
const lerpP=(p,q,t)=>({x:lerp(p.x,q.x,t), y:lerp(p.y,q.y,t)});
function areaPoly(p){let a=0;for(let i=0;i<p.length;i++){const q=p[(i+1)%p.length];a+=p[i].x*q.y-q.x*p[i].y;}return Math.abs(a)/2;}
function centroidOf(p){let x=0,y=0;for(const q of p){x+=q.x;y+=q.y;}return{x:x/p.length,y:y/p.length};}
function anglesOf(poly){ // 三角形三个内角(弧度)
const ang=(U,V,Ww)=>{
const v1=sub(V,U), v2=sub(Ww,U);
return Math.acos(clamp(dot(v1,v2)/(len(v1)*len(v2)),-1,1));
};
const [A,B,C]=poly;
return [ang(A,B,C), ang(B,C,A), ang(C,A,B)];
}
function rr(c,x,y,w,h,r){c.beginPath();c.moveTo(x+r,y);c.arcTo(x+w,y,x+w,y+h,r);
c.arcTo(x+w,y+h,x,y+h,r);c.arcTo(x,y+h,x,y,r);c.arcTo(x,y,x+w,y,r);c.closePath();}
// ---------- 画布尺寸 ----------
let W=800,H=540,DPR=1;
function sizeCanvas(cv,ctx,hCss){
const w = cv.clientWidth || cv.parentElement.clientWidth;
const h = hCss || cv.clientHeight;
DPR = window.devicePixelRatio||1;
cv.width = Math.round(w*DPR); cv.height = Math.round(h*DPR);
ctx.setTransform(DPR,0,0,DPR,0,0);
return [w,h];
}
function sizeAll(){
[W,H] = sizeCanvas(geo,g,540);
sizeCanvas(histCv,hc,150);
sizeCanvas(sparkCv,sc,0);
if(S.rect){ S.rect.cx=W/2; S.rect.cy=H*0.47; }
}
// ---------- 全局状态 ----------
const S = {
running:true, speed:1, iter:0,
phase:'idle', t:0,
rect:null, area0:1,
P:null, tris:[], cutSegs:[], remIdx:-1,
remAngles:null, remArea:0, thetaM:0,
nextRect:null, resetNext:false,
flyData:null, RT:null,
recorded:false, recFlash:0,
fast:0
};
// ---------- 后台统计 ----------
const stats = {
nTri:0, sumS:0, // 取走三角形数 / 内角和累加
acute:0, obtuse:0, // 锐角 / 钝角三角形计数
binsA:new Float64Array(BINS), // 锐角三角形的角度直方图
binsO:new Float64Array(BINS), // 钝角三角形的角度直方图
cutN:0, cutSin:0, // 测量切割角样本(Buffon 估计器)
spark:[], sparkTick:1,
log:[]
};
function resetStats(){
stats.nTri=0; stats.sumS=0; stats.acute=0; stats.obtuse=0;
stats.binsA.fill(0); stats.binsO.fill(0);
stats.cutN=0; stats.cutSin=0; stats.spark.length=0; stats.sparkTick=1;
stats.log.length=0;
refreshStats(); drawHist(); drawSpark();
}
// ---------- 几何构建 ----------
function rectCorners(R){
const x=R.cx-R.w/2, y=R.cy-R.h/2;
return [{x,y},{x:x+R.w,y},{x:x+R.w,y:y+R.h},{x,y:y+R.h}];
}
function pieceColor(i){ return `hsla(${(S.iter*47+i*67)%360},66%,58%,0.92)`; }
function buildIteration(){
const R=S.rect, cs=rectCorners(R);
// 随机内点 P → 4 刀切割(P 均匀随机 ⇒ 切割角度完全随机)
const P={x:R.cx+(Math.random()-.5)*R.w*0.6, y:R.cy+(Math.random()-.5)*R.h*0.6};
const tris=[];
for(let i=0;i<4;i++){
tris.push({poly:[cs[i], cs[(i+1)%4], P], color:pieceColor(i)});
}
const segs=cs.map(c=>({a:c,b:P}));
// 以 60% 概率追加随机一刀(把某块再切成两片)
if(Math.random()<0.6){
const i=(Math.random()*tris.length)|0;
const t=tris[i];
const e=t.poly.filter(v=>v!==P); // 外侧边(矩形边)的两个端点
const u=rand(0.15,0.85);
const Q={x:lerp(e[0].x,e[1].x,u), y:lerp(e[0].y,e[1].y,u)};
tris.splice(i,1,
{poly:[e[0],Q,P], color:pieceColor(4)},
{poly:[Q,e[1],P], color:pieceColor(5)});
segs.push({a:P,b:Q});
}
return {P,tris,segs};
}
// ---------- 统计记录 ----------
function recordCore(angs){
const s = angs[0]+angs[1]+angs[2]; // 理论上恒等于 π
stats.nTri++; stats.sumS += s;
const acute = Math.max(angs[0],angs[1],angs[2]) < Math.PI/2;
if(acute) stats.acute++; else stats.obtuse++;
const bins = acute?stats.binsA:stats.binsO;
for(const a of angs){
const k=Math.min(BINS-1, Math.floor(a/Math.PI*BINS));
bins[k]++;
}
stats.log.unshift({n:stats.nTri, d:angs.map(r=>r*180/Math.PI), acute, s});
if(stats.log.length>9) stats.log.pop();
if(--stats.sparkTick<=0){ stats.sparkTick=4; pushSpark(); }
}
function pushSpark(){
if(stats.cutN<8) return;
const p2=2/(stats.cutSin/stats.cutN);
const d=Math.max(0,-Math.log10(Math.abs(p2-Math.PI)/Math.PI));
stats.spark.push(d);
if(stats.spark.length>240) stats.spark.shift();
}
function piBuffon(){ return stats.cutN>=2 ? 2/(stats.cutSin/stats.cutN) : NaN; }
function piHistCoarse(){
let tot=0,sx=0;
for(let k=0;k<BINS;k++){
const c=stats.binsA[k]+stats.binsO[k];
tot+=c; sx+=c*((k+0.5)/BINS*Math.PI);
}
return tot>0 ? 3*sx/tot : NaN;
}
// ---------- 迭代生命周期 ----------
function resetSquare(){
const side=Math.min(W,H)*0.6;
S.rect={cx:W/2, cy:H*0.47, w:side, h:side};
S.area0=side*side;
S.resetNext=false;
}
function startIteration(){
S.iter++;
const R=S.rect;
const b=buildIteration();
S.P=b.P; S.tris=b.tris; S.cutSegs=b.segs;
S.remIdx=(Math.random()*S.tris.length)|0;
// 均匀随机测量角 θ∈[0,π):Buffon 型估计器的独立样本
S.thetaM=Math.random()*Math.PI;
stats.cutN++; stats.cutSin+=Math.sin(S.thetaM);
const rem=S.tris[S.remIdx];
S.remAngles=anglesOf(rem.poly);
S.remArea=areaPoly(rem.poly);
// 新矩形:面积守恒 A' = A − ΔA,长宽比随机
const A2=R.w*R.h-S.remArea;
const asp=rand(0.8,1.5);
S.nextRect={cx:R.cx, cy:R.cy, w:Math.sqrt(A2*asp), h:Math.sqrt(A2/asp)};
S.resetNext = (A2 < 0.15*S.area0) || S.nextRect.w<64 || S.nextRect.h<64;
S.recorded=false;
setPhase('cut');
}
function setPhase(p){
S.phase=p; S.t=0;
if(p==='fly'){
const NR=S.nextRect;
const RT={x:NR.cx-NR.w/2, y:NR.cy-NR.h/2, w:NR.w, h:NR.h};
const pcs=S.tris.filter((_,i)=>i!==S.remIdx);
const slots=layoutSlots(pcs,RT);
S.flyData=pcs.map((pc,i)=>({pc,slot:slots[i]}));
S.RT=RT;
}
}
function nextPhase(){
switch(S.phase){
case 'cut': setPhase('flash'); break;
case 'flash': setPhase('remove'); break;
case 'remove':
if(S.resetNext){ setPhase('reset'); } else { setPhase('fly'); }
break;
case 'fly': setPhase('merge'); break;
case 'merge': setPhase('settle'); break;
case 'settle': S.rect=S.nextRect; S.phase='idle'; break;
case 'reset': resetSquare(); S.phase='idle'; break;
default: S.phase='idle';
}
}
// 快进模式:纯数学迭代(无动画),用于批量采样
function fastIteration(){
const R=S.rect;
const b=buildIteration();
const remI=(Math.random()*b.tris.length)|0;
const rem=b.tris[remI];
recordCore(anglesOf(rem.poly));
const th=Math.random()*Math.PI;
stats.cutN++; stats.cutSin+=Math.sin(th);
const A2=R.w*R.h-areaPoly(rem.poly);
if(A2 < 0.15*S.area0){ resetSquare(); S.iter++; return; }
const asp=rand(0.8,1.5);
const w=Math.sqrt(A2*asp), h=Math.sqrt(A2/asp);
if(w<64||h<64){ resetSquare(); S.iter++; return; }
S.rect={cx:R.cx, cy:R.cy, w, h};
S.iter++;
}
// ---------- 重拼布局:把三角形旋到底边水平,逐行摆入目标矩形 ----------
function layoutSlots(pcs, RT){
const slots=[], pad=8;
const x0=RT.x+pad;
let yBase=RT.y+RT.h-pad, x=x0, rowH=0;
for(const pc of pcs){
const [A,B,C]=pc.poly;
const Vs=[A,B,C];
// 选最长边作为底边
let bi=0, blen=0;
const edges=[[A,B],[B,C],[C,A]];
edges.forEach((e,i)=>{const L=dist(e[0],e[1]); if(L>blen){blen=L;bi=i;}});
const bA=edges[bi][0], bB=edges[bi][1];
const apex=Vs.find(v=>v!==bA&&v!==bB);
const ex=norm(sub(bB,bA));
const hAlt=2*areaPoly(pc.poly)/blen;
const apexProj=dot(sub(apex,bA),ex);
const spanMin=Math.min(0,apexProj), spanMax=Math.max(blen,apexProj);
const span=spanMax-spanMin;
if(x-x0+span>RT.w-2*pad && x>x0){ x=x0; yBase-=rowH+pad; rowH=0; }
// 目标顶点:底边水平、apex 朝上(屏幕 y 向下)
const tA={x:x-spanMin, y:yBase};
const tB={x:x-spanMin+blen, y:yBase};
const tC={x:x-spanMin+apexProj, y:yBase-hAlt};
// 旋转量:把底边转到 +x 方向,并保证 apex 在底边上方
const phi0=Math.atan2(bB.y-bA.y, bB.x-bA.x);
let dRot=-phi0;
if(rotVec(sub(apex,bA),dRot).y>0) dRot+=Math.PI;
const srcC=centroidOf(pc.poly);
const tgtC={x:(tA.x+tB.x+tC.x)/3, y:(tA.y+tB.y+tC.y)/3};
slots.push({dx:tgtC.x-srcC.x, dy:tgtC.y-srcC.y, rot:dRot});
x+=span+pad; rowH=Math.max(rowH,hAlt);
}
return slots;
}
// ---------- 主更新 ----------
function update(dt){
if(S.phase==='idle'){
if(S.fast>0){
const k=Math.min(800,S.fast);
for(let i=0;i<k;i++) fastIteration();
S.fast-=k;
refreshStats(); drawHist(); drawSpark();
}else if(S.running){
startIteration();
}
return;
}
if(!S.running) return;
S.t += dt*S.speed/DUR[S.phase];
if(S.phase==='remove' && !S.recorded && S.t>0.1){
recordCore(S.remAngles);
S.recFlash=1;
}
if(S.t>=1) nextPhase();
}
// ---------- 绘制:几何视图 ----------
function drawGrid(){
g.strokeStyle='rgba(80,120,200,0.06)'; g.lineWidth=1;
g.beginPath();
for(let x=0;x<W;x+=40){g.moveTo(x,0);g.lineTo(x,H);}
for(let y=0;y<H;y+=40){g.moveTo(0,y);g.lineTo(W,y);}
g.stroke();
}
function drawRectSolid(R,alpha,color){
g.save(); g.globalAlpha=alpha;
const x=R.cx-R.w/2, y=R.cy-R.h/2;
g.fillStyle=color||'#1c2a4a';
g.fillRect(x,y,R.w,R.h);
g.strokeStyle='#3f62a8'; g.lineWidth=1.5;
g.strokeRect(x,y,R.w,R.h);
g.fillStyle='rgba(160,190,255,.55)';
g.font='11px '+MONO;
g.fillText(`${R.w|0} × ${R.h|0}`, x, y+R.h+14);
g.restore();
}
function drawPiece(pc,dx,dy,rot,alpha,scale){
const c=pc.centroid||(pc.centroid=centroidOf(pc.poly));
g.save(); g.globalAlpha=alpha;
g.translate(c.x+dx, c.y+dy); g.rotate(rot); if(scale!==1)g.scale(scale,scale);
g.beginPath();
pc.poly.forEach((p,i)=> i?g.lineTo(p.x-c.x,p.y-c.y):g.moveTo(p.x-c.x,p.y-c.y));
g.closePath();
g.fillStyle=pc.color; g.fill();
g.strokeStyle='rgba(5,10,20,.5)'; g.lineWidth=1.5; g.stroke();
g.restore();
}
function collectorPos(){ return {x:W-96,y:36}; }
function drawCollector(){
const c=collectorPos();
g.save();
g.globalAlpha=0.92;
g.fillStyle= S.recFlash>0.05 ? `rgba(83,216,255,${0.12+0.35*S.recFlash})` : 'rgba(83,216,255,0.08)';
rr(g,c.x-84,c.y-22,168,44,8); g.fill();
g.strokeStyle='rgba(83,216,255,0.5)'; g.stroke();
g.fillStyle='#9fdcff'; g.font='12px '+MONO; g.textAlign='center';
g.fillText('∠ 角度记录器', c.x, c.y-5);
g.fillStyle='#e8f6ff';
g.fillText(`已记录 ${stats.nTri} 个三角形`, c.x, c.y+12);
g.textAlign='left';
g.restore();
S.recFlash*=0.93;
}
// 测量切割线:均匀随机角 θ(Buffon 样本的可视化)
function drawMeasureLine(R,P,theta,alpha){
g.save();
const x=R.cx-R.w/2, y=R.cy-R.h/2;
g.beginPath(); g.rect(x,y,R.w,R.h); g.clip();
const d={x:Math.cos(theta),y:Math.sin(theta)};
const L=Math.max(R.w,R.h)*1.2;
g.strokeStyle=`rgba(255,107,157,${alpha})`;
g.setLineDash([7,6]); g.lineWidth=1.4;
g.beginPath();
g.moveTo(P.x-d.x*L,P.y-d.y*L); g.lineTo(P.x+d.x*L,P.y+d.y*L);
g.stroke(); g.setLineDash([]);
g.restore();
g.save();
g.fillStyle=`rgba(255,107,157,${Math.min(1,alpha+0.2)})`;
g.font='11px '+MONO;
g.fillText(`测量切割线 θ = ${(theta*180/Math.PI).toFixed(1)}°(均匀随机)`, x+8, y-8);
g.restore();
}
function drawCutPhase(){
const R=S.rect, t=S.t;
drawRectSolid(R,1);
drawMeasureLine(R,S.P,S.thetaM, 0.25+0.5*t);
// 逐条绘制切割线(进度动画)
const n=S.cutSegs.length;
g.save();
g.strokeStyle='#7ee0ff'; g.lineWidth=2;
g.shadowColor='#53d8ff'; g.shadowBlur=8;
for(let i=0;i<n;i++){
const u=clamp(t*n-i,0,1);
if(u<=0) continue;
const s=S.cutSegs[i], e=lerpP(s.a,s.b,easeIO(u));
g.beginPath(); g.moveTo(s.a.x,s.a.y); g.lineTo(e.x,e.y); g.stroke();
}
g.restore();
if(t>0.15){
g.fillStyle='#fff';
g.beginPath(); g.arc(S.P.x,S.P.y,3.5,0,7); g.fill();
}
}
function drawAngleLabels(tri,alpha){
const Vs=tri.poly, degs=S.remAngles.map(r=>r*180/Math.PI);
g.save(); g.globalAlpha=alpha; g.font='12px '+MONO;
for(let i=0;i<3;i++){
const V=Vs[i], P1=Vs[(i+1)%3], P2=Vs[(i+2)%3];
const bis=norm(add(norm(sub(P1,V)),norm(sub(P2,V))));
g.fillStyle='#ffffff';
g.fillText(degs[i].toFixed(1)+'°', V.x+bis.x*26-14, V.y+bis.y*26+4);
}
g.restore();
}
function drawRemovePhase(){
const t=S.t;
const cp=collectorPos();
S.tris.forEach((pc,i)=>{
if(i===S.remIdx) return;
drawPiece(pc,0,0,0,1,1);
});
const rem=S.tris[S.remIdx];
if(t<0.45){
const pulse=1+0.04*Math.sin(t*40);
drawPiece(rem,0,0,0,1,pulse);
drawAngleLabels(rem, clamp(t/0.12,0,1));
// 高亮描边
const c=centroidOf(rem.poly);
g.save(); g.translate(c.x,c.y); g.scale(pulse,pulse);
g.beginPath();
rem.poly.forEach((p,j)=> j?g.lineTo(p.x-c.x,p.y-c.y):g.moveTo(p.x-c.x,p.y-c.y));
g.closePath(); g.strokeStyle='#ff5c7a'; g.lineWidth=2.5; g.stroke();
g.restore();
}else{
const u=easeIO((t-0.45)/0.55);
const c=centroidOf(rem.poly);
drawPiece(rem,(cp.x-c.x)*u,(cp.y-c.y)*u, u*1.5, 1-u, 1-0.75*u);
// 记录信息浮牌
const d=S.remAngles.map(r=>r*180/Math.PI);
g.save(); g.globalAlpha=clamp((t-0.5)/0.2,0,1);
const txt=`记录 △#${stats.nTri}: ${d[0].toFixed(1)}° + ${d[1].toFixed(1)}° + ${d[2].toFixed(1)}° = π`;
g.font='12px '+MONO;
const tw=g.measureText(txt).width;
g.fillStyle='rgba(10,17,34,.92)';
rr(g, cp.x-tw/2-10, cp.y+30, tw+20, 26, 6); g.fill();
g.strokeStyle='rgba(83,216,255,.5)'; g.stroke();
g.fillStyle='#bfeaff'; g.textAlign='center';
g.fillText(txt, cp.x, cp.y+47);
g.textAlign='left'; g.restore();
}
}
function drawFlyPhase(){
const RT=S.RT, t=S.t;
// 目标矩形虚线轮廓
g.save();
g.strokeStyle='rgba(83,216,255,.7)'; g.setLineDash([6,5]); g.lineWidth=1.5;
g.strokeRect(RT.x,RT.y,RT.w,RT.h); g.setLineDash([]);
g.fillStyle='rgba(83,216,255,.6)'; g.font='11px '+MONO;
g.fillText('目标矩形(面积守恒)', RT.x, RT.y-6);
g.restore();
S.flyData.forEach((fd,j)=>{
const u=clamp(t*1.35-j*0.12,0,1), e=easeIO(u);
drawPiece(fd.pc, fd.slot.dx*e, fd.slot.dy*e, fd.slot.rot*e, 1, 1);
});
}
function drawMergePhase(){
const t=easeIO(S.t);
S.flyData.forEach(fd=>{
drawPiece(fd.pc, fd.slot.dx, fd.slot.dy, fd.slot.rot, 1-t, 1);
});
drawRectSolid(S.nextRect, t);
// 面积守恒浮牌
const A=(S.rect.w*S.rect.h)|0, dA=S.remArea|0, A2=(S.nextRect.w*S.nextRect.h)|0;
g.save(); g.globalAlpha=t;
g.font='12px '+MONO; g.fillStyle='#bfeaff'; g.textAlign='center';
g.fillText(`面积守恒:${A} − ${dA} = ${A2} px² → 新矩形`, S.nextRect.cx, S.nextRect.cy);
g.textAlign='left'; g.restore();
}
function drawResetPhase(){
const t=S.t;
const side=Math.min(W,H)*0.6;
g.save();
g.strokeStyle=`rgba(255,180,84,${1-t})`; g.lineWidth=2;
g.beginPath(); g.arc(W/2,H*0.47, t*Math.max(W,H)*0.6, 0, 7); g.stroke();
g.globalAlpha=easeIO(clamp(t*1.4-0.3,0,1));
const s2=side*(0.6+0.4*easeIO(t));
g.fillStyle='#1c2a4a';
g.fillRect(W/2-s2/2, H*0.47-s2/2, s2, s2);
g.strokeStyle='#3f62a8'; g.strokeRect(W/2-s2/2, H*0.47-s2/2, s2, s2);
g.fillStyle='#ffd9a0'; g.font='13px '+MONO; g.textAlign='center';
g.fillText('面积过小 → 重置为新正方形(统计数据继续累积)', W/2, H*0.47);
g.textAlign='left'; g.restore();
}
function drawHUD(){
g.save();
g.font='12px '+MONO; g.fillStyle='rgba(160,190,255,.8)';
g.fillText(`迭代 #${S.iter}`, 14, 22);
if(S.rect) g.fillText(`矩形 ${S.rect.w|0} × ${S.rect.h|0} px · 面积 ${(S.rect.w*S.rect.h)|0}`, 14, 40);
g.fillStyle='rgba(125,141,177,.8)';
g.fillText(`切割角样本 ${stats.cutN} · 三角形样本 ${stats.nTri}`, 14, 58);
// 阶段说明(底部居中)
const cap=PHASE_CN[S.phase]||'';
g.font='13px "Microsoft YaHei",sans-serif';
const tw=g.measureText(cap).width;
g.fillStyle='rgba(10,17,34,.85)';
rr(g, W/2-tw/2-14, H-38, tw+28, 26, 13); g.fill();
g.strokeStyle='rgba(83,216,255,.3)'; g.stroke();
g.fillStyle='#cfe6ff'; g.textAlign='center';
g.fillText(cap, W/2, H-20);
g.textAlign='left';
g.restore();
}
function draw(){
g.clearRect(0,0,W,H);
drawGrid();
drawCollector();
switch(S.phase){
case 'idle': if(S.rect) drawRectSolid(S.rect,1); break;
case 'cut': drawCutPhase(); break;
case 'flash':
drawMeasureLine(S.rect,S.P,S.thetaM,0.6);
S.tris.forEach(pc=>drawPiece(pc,0,0,0,easeIO(S.t),1));
break;
case 'remove': drawRemovePhase(); break;
case 'fly': drawFlyPhase(); break;
case 'merge': drawMergePhase(); break;
case 'settle': drawRectSolid(S.nextRect,1); break;
case 'reset': drawResetPhase(); break;
}
drawHUD();
}
// ---------- 绘制:直方图 ----------
function drawHist(){
const w=histCv.clientWidth, h=150;
hc.clearRect(0,0,w,h);
let maxCol=1;
for(let k=0;k<BINS;k++) maxCol=Math.max(maxCol, stats.binsA[k]+stats.binsO[k]);
const bw=w/BINS;
for(let k=0;k<BINS;k++){
const a=stats.binsA[k], o=stats.binsO[k];
const hA=(h-22)*a/maxCol, hO=(h-22)*o/maxCol;
hc.fillStyle='rgba(255,180,84,.85)';
hc.fillRect(k*bw+1, h-18-hO, bw-2, hO);
hc.fillStyle='rgba(83,216,255,.85)';
hc.fillRect(k*bw+1, h-18-hO-hA, bw-2, hA);
}
// 90° 分界线
const x90=w*0.5;
hc.strokeStyle='rgba(255,92,122,.7)'; hc.setLineDash([4,4]);
hc.beginPath(); hc.moveTo(x90,4); hc.lineTo(x90,h-18); hc.stroke(); hc.setLineDash([]);
hc.fillStyle='#ff5c7a'; hc.font='10px '+MONO;
hc.fillText('90°', x90+3, 12);
// 坐标轴标签
hc.fillStyle='rgba(125,141,177,.9)';
[[0,'0°'],[0.25,'45°'],[0.5,'90°'],[0.75,'135°'],[1,'180°']].forEach(([f,t])=>{
hc.fillText(t, clamp(w*f-10,2,w-26), h-5);
});
// 图例
hc.fillStyle='rgba(83,216,255,.9)'; hc.fillRect(w-118,6,8,8);
hc.fillStyle='#9fb4de'; hc.fillText('锐角三角形角度', w-106, 13);
hc.fillStyle='rgba(255,180,84,.9)'; hc.fillRect(w-118,19,8,8);
hc.fillStyle='#9fb4de'; hc.fillText('钝角三角形角度', w-106, 26);
}
// ---------- 绘制:蒙特卡洛收敛火花线 ----------
function drawSpark(){
const w=sparkCv.clientWidth, h=sparkCv.clientHeight||72;
sc.clearRect(0,0,w,h);
sc.strokeStyle='rgba(30,42,68,.9)';
for(let i=1;i<=4;i++){
const y=h-(i/5)*h;
sc.beginPath(); sc.moveTo(0,y); sc.lineTo(w,y); sc.stroke();
}
const data=stats.spark;
if(data.length<2){
sc.fillStyle='rgba(125,141,177,.7)'; sc.font='11px '+MONO;
sc.fillText('等待样本累积…', 8, h/2);
return;
}
let maxD=3; for(const d of data) maxD=Math.max(maxD,d);
maxD+=0.8;
sc.beginPath();
data.forEach((d,i)=>{
const x=i/(data.length-1)*w, y=h-(d/maxD)*(h-8)-2;
i?sc.lineTo(x,y):sc.moveTo(x,y);
});
sc.strokeStyle='#42e696'; sc.lineWidth=1.5; sc.stroke();
sc.fillStyle='rgba(125,141,177,.9)'; sc.font='10px '+MONO;
sc.fillText(`MC 正确位数(纵轴) 峰值≈${maxD.toFixed(1)} · 斜率≈-½logN`, 6, 11);
}
// ---------- 统计面板刷新 ----------
function refreshStats(){
const N=stats.nTri;
if(N>0){
const p1=stats.sumS/N; // 主估计器:角度积分
const a=p1.toFixed(13), b=Math.PI.toFixed(13);
let m=0; while(m<a.length && a[m]===b[m]) m++;
const dec=Math.max(0,m-2); // 小数点后稳定位数
el.piHat.innerHTML=
`<span class="ok">${a.slice(0,m)}</span><span class="bad">${a.slice(m)}</span>`;
const err=Math.abs(p1-Math.PI);
el.err.textContent = err===0 ? '0(机器精度内)' : err.toExponential(2);
el.digits.textContent = dec+' 位';
el.badge.className = 'badge '+(dec>=10?'ok':'no');
el.badge.textContent = dec>=10 ? `✓ 已精确 ${dec} 位(≥10 达标)` : `收敛中 · ${dec} 位`;
}else{
el.piHat.textContent='—'; el.err.textContent='—';
el.digits.textContent='—';
el.badge.className='badge no'; el.badge.textContent='目标 ≥ 10 位';
}
el.n.textContent=N;
el.ang.textContent=N*3;
el.acute.textContent=stats.acute;
el.obtuse.textContent=stats.obtuse;
el.pacute.textContent = N?`P = ${(stats.acute/N).toFixed(4)}`:'P = —';
el.pobtuse.textContent = N?`P = ${(stats.obtuse/N).toFixed(4)}`:'P = —';
const p2=piBuffon();
if(isFinite(p2)){
el.p2.textContent=p2.toFixed(10);
el.p2e.textContent=Math.abs(p2-Math.PI).toExponential(2);
}else{ el.p2.textContent='—'; el.p2e.textContent='—'; }
el.cutn.textContent=stats.cutN;
const ph=piHistCoarse();
el.ph.textContent=isFinite(ph)?ph.toFixed(5):'—';
// 最近样本表
el.log.innerHTML=stats.log.map(r=>
`<tr><td>${r.n}</td>`+
r.d.map(x=>`<td>${x.toFixed(2)}°</td>`).join('')+
`<td class="${r.acute?'acc':'obt'}">${r.acute?'锐角':'钝角'}</td>`+
`<td>${r.s.toFixed(13)}</td></tr>`).join('');
// 状态行与按钮
el.status.textContent=
`第 ${S.iter} 轮 · ${PHASE_CN[S.phase]} · 面积 ${(S.rect?S.rect.w*S.rect.h:0)|0} px²`+
(S.fast>0?` · 快进队列 ${S.fast}`:'');
el.btnFast.textContent = `⚡ 后台快进 ×5000${S.fast>0?`(排队 ${S.fast})`:''}`;
}
// ---------- 控件 ----------
el.btnRun.onclick=()=>{
S.running=!S.running;
el.btnRun.textContent=S.running?'⏸ 暂停':'▶ 继续';
};
el.btnFast.onclick=()=>{ S.fast+=5000; };
el.btnReset.onclick=()=>{ resetStats(); };
el.speed.oninput=()=>{
S.speed=parseFloat(el.speed.value);
el.speedVal.textContent=S.speed.toFixed(1)+'×';
};
window.addEventListener('resize',()=>{ sizeAll(); });
// ---------- 主循环 ----------
let last=performance.now(), acc1=0, acc2=0;
function loop(now){
const dt=Math.min(0.05,(now-last)/1000); last=now;
update(dt);
draw();
acc1+=dt; acc2+=dt;
if(acc1>0.12){ acc1=0; refreshStats(); }
if(acc2>0.3){ acc2=0; drawHist(); drawSpark(); }
requestAnimationFrame(loop);
}
// ---------- 启动 ----------
sizeAll();
resetSquare();
refreshStats(); drawHist(); drawSpark();
requestAnimationFrame(loop);
</script>
</body>
</html>