分享图
动画工坊
引擎就绪
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>窄风道气幕隔声防堵 — IFR原理动画</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@300;500;700&family=IBM+Plex+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#050a15;color:#c8d6e5;font-family:'IBM Plex Mono',monospace;overflow:hidden;height:100vh;display:flex;flex-direction:column}
#title-bar{padding:12px 24px;display:flex;align-items:center;gap:16px;background:rgba(5,10,21,0.95);border-bottom:1px solid rgba(34,211,238,0.12);flex-shrink:0}
#title-bar h1{font-family:'Rajdhani',sans-serif;font-weight:700;font-size:20px;color:#22d3ee;letter-spacing:2px;text-transform:uppercase}
#title-bar .subtitle{font-size:11px;color:#475569;font-weight:300}
.ifr-badge{margin-left:auto;padding:3px 12px;border:1px solid rgba(34,211,238,0.3);border-radius:4px;font-family:'Rajdhani',sans-serif;font-size:11px;color:#22d3ee;letter-spacing:1px;background:rgba(34,211,238,0.05)}
#canvas-wrap{flex:1;position:relative;min-height:0}
canvas{display:block;width:100%;height:100%}
#controls{padding:10px 24px;background:rgba(5,10,21,0.97);border-top:1px solid rgba(34,211,238,0.12);display:flex;align-items:center;gap:28px;flex-wrap:wrap;flex-shrink:0}
.cg{display:flex;align-items:center;gap:8px}
.cg label{font-size:11px;color:#64748b;white-space:nowrap}
.cg .val{font-size:11px;color:#22d3ee;min-width:36px;text-align:right;font-weight:500}
input[type=range]{-webkit-appearance:none;width:110px;height:3px;background:#1e293b;border-radius:2px;outline:none}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:13px;height:13px;background:#22d3ee;border-radius:50%;cursor:pointer;box-shadow:0 0 8px rgba(34,211,238,0.45)}
.legend{display:flex;gap:14px;margin-left:auto}
.li{display:flex;align-items:center;gap:5px;font-size:10px;color:#64748b}
.ld{width:7px;height:7px;border-radius:50%}
</style>
</head>
<body>
<div id="title-bar">
  <h1>窄风道气幕隔声防堵原理</h1>
  <span class="subtitle">双重隔离 · 源头消声 · 气幕防堵</span>
  <span class="ifr-badge">IFR 最终理想解</span>
</div>
<div id="canvas-wrap"><canvas id="cv"></canvas></div>
<div id="controls">
  <div class="cg"><label>气幕压力</label><input type="range" id="slP" min="0" max="100" value="82"><span class="val" id="vP">82%</span></div>
  <div class="cg"><label>降噪增益</label><input type="range" id="slA" min="0" max="100" value="80"><span class="val" id="vA">80%</span></div>
  <div class="legend">
    <div class="li"><div class="ld" style="background:#fb923c;box-shadow:0 0 4px #fb923c"></div>主气流</div>
    <div class="li"><div class="ld" style="background:#22d3ee;box-shadow:0 0 4px #22d3ee"></div>气幕射流</div>
    <div class="li"><div class="ld" style="background:#d97706;box-shadow:0 0 4px #d97706"></div>粉尘</div>
    <div class="li"><div class="ld" style="background:#f87171;box-shadow:0 0 4px #f87171"></div>噪声波</div>
    <div class="li"><div class="ld" style="background:#4ade80;box-shadow:0 0 4px #4ade80"></div>反相声波</div>
  </div>
</div>
<script>
/* ====== 画布初始化 ====== */
const cv = document.getElementById('cv');
const c = cv.getContext('2d');
const wrap = document.getElementById('canvas-wrap');
let W, H, dpr;

function resize() {
  dpr = Math.min(window.devicePixelRatio || 1, 2);
  W = wrap.clientWidth; H = wrap.clientHeight;
  cv.width = W * dpr; cv.height = H * dpr;
  cv.style.width = W + 'px'; cv.style.height = H + 'px';
  c.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
resize();

/* ====== 虚拟坐标 (1400×800) ====== */
const VW = 1400, VH = 800;
const sx = x => x / VW * W;
const sy = y => y / VH * H;
const sl = l => l / VW * W;

/* ====== 风道几何 ====== */
const D = {
  il: 70, ir: 230, it: 175, ib: 525,       // 宽入口
  cl: 230, cr: 320,                           // 收缩段
  nl: 320, nr: 1120, nt: 245, nb: 495,       // 窄段
  wt: 14                                      // 壁厚
};

/* ====== 射流孔 ====== */
const jStart = 340, jEnd = 1100, jSp = 48;
const jHoles = [];
for (let x = jStart; x <= jEnd; x += jSp) jHoles.push(x);
const jetAngle = 15 * Math.PI / 180;

/* ====== 控制参数 ====== */
let cPressure = 0.82, ancGain = 0.80;
document.getElementById('slP').oninput = function() { cPressure = this.value / 100; document.getElementById('vP').textContent = this.value + '%'; };
document.getElementById('slA').oninput = function() { ancGain = this.value / 100; document.getElementById('vA').textContent = this.value + '%'; };

/* ====== 粒子系统 ====== */
let t = 0;
const flows = [], curtains = [], dusts = [], sparks = [];

// 获取某x处风道上下界(内壁)
function ductBounds(x) {
  if (x < D.cl) return [D.it + 12, D.ib - 12];
  if (x < D.cr) {
    const r = (x - D.cl) / (D.cr - D.cl);
    const s = r * r * (3 - 2 * r); // smoothstep
    return [D.it + 12 + s * (D.nt + 14 - D.it - 12), D.ib - 12 - s * (D.ib - 12 - D.nb + 14)];
  }
  return [D.nt + 14, D.nb - 14];
}

// 气幕屏障位置
function curtainBarriers() {
  const depth = (D.nb - D.nt) * 0.14 * cPressure;
  return [D.nt + 14 + depth, D.nb - 14 - depth];
}

class Flow {
  constructor() { this.reset(); }
  reset() {
    this.x = D.il - 10 + Math.random() * 40;
    this.y = D.it + 30 + Math.random() * (D.ib - D.it - 60);
    this.vx = 1.8 + Math.random() * 0.8;
    this.vy = 0; this.ph = Math.random() * 6.28;
    this.sz = 1.8 + Math.random() * 1.8;
  }
  update(dt) {
    const [top, bot] = ductBounds(this.x);
    const [ct, cb] = this.x >= D.nl ? curtainBarriers() : [top, bot];
    const effTop = this.x >= D.nl ? ct : top;
    const effBot = this.x >= D.nl ? cb : bot;
    // 窄段加速
    if (this.x >= D.nl) {
      this.vx = 3.0 + Math.random() * 0.4;
      this.vy += Math.sin(t * 6 + this.ph + this.x * 0.015) * 0.18;
    } else if (this.x >= D.cl) {
      this.vx = 2.2 + Math.random() * 0.3;
    }
    this.x += this.vx * dt * 60;
    this.y += this.vy * dt * 60;
    this.vy *= 0.93;
    if (this.y < effTop + 2) { this.y = effTop + 2; this.vy = Math.abs(this.vy) * 0.4; }
    if (this.y > effBot - 2) { this.y = effBot - 2; this.vy = -Math.abs(this.vy) * 0.4; }
    if (this.x > D.nr + 30) this.reset();
  }
}

class Curtain {
  constructor(side, hx) { this.side = side; this.hx = hx; this.reset(); }
  reset() {
    this.x = this.hx; this.y = this.side === 't' ? D.nt + 2 : D.nb - 2;
    const spd = 1.2 + Math.random() * 0.8;
    this.vx = Math.cos(jetAngle) * spd;
    this.vy = this.side === 't' ? Math.sin(jetAngle) * spd : -Math.sin(jetAngle) * spd;
    this.sz = 1.3 + Math.random() * 1.2;
    this.age = 0; this.maxAge = 2.5 + Math.random() * 1.5;
  }
  update(dt) {
    this.age += dt;
    const depth = (D.nb - D.nt) * 0.15;
    this.x += this.vx * dt * 60 * cPressure;
    this.y += this.vy * dt * 60 * cPressure;
    this.vy *= 0.97;
    if (this.side === 't' && this.y > D.nt + depth) { this.y = D.nt + depth; this.vy = 0; }
    if (this.side === 'b' && this.y < D.nb - depth) { this.y = D.nb - depth; this.vy = 0; }
    if (this.age > this.maxAge || this.x > D.nr + 5) this.reset();
  }
  get life() { return Math.max(0, 1 - this.age / this.maxAge); }
}

class Dust {
  constructor() { this.reset(); }
  reset() {
    this.x = D.nl + Math.random() * (D.nr - D.nl) * 0.4;
    this.y = (D.nt + D.nb) / 2 + (Math.random() - 0.5) * (D.nb - D.nt) * 0.4;
    this.vx = 1.0 + Math.random() * 0.6;
    this.vy = (Math.random() - 0.5) * 0.2;
    this.sz = 2.5 + Math.random() * 2;
    this.drift = Math.random() > 0.5 ? 1 : -1;
    this.dep = false; this.depT = 0;
  }
  update(dt) {
    if (this.dep) { this.depT += dt; if (this.depT > 3) this.reset(); return; }
    this.x += this.vx * dt * 60;
    this.vy += this.drift * 0.025;
    this.y += this.vy * dt * 60;
    const [ct, cb] = curtainBarriers();
    const repF = cPressure * 0.4;
    if (this.y < ct + 8 && this.y > D.nt) { this.vy += repF; this.drift = 1; spawnSparks(this.x, this.y, 't'); }
    if (this.y > cb - 8 && this.y < D.nb) { this.vy -= repF; this.drift = -1; spawnSparks(this.x, this.y, 'b'); }
    this.vy *= 0.94;
    if (this.y <= D.nt + 4) { if (cPressure < 0.25) { this.dep = true; this.y = D.nt + 4; } else { this.vy = 0.5; } }
    if (this.y >= D.nb - 4) { if (cPressure < 0.25) { this.dep = true; this.y = D.nb - 4; } else { this.vy = -0.5; } }
    if (this.x > D.nr + 15) this.reset();
  }
}

function spawnSparks(x, y, side) {
  if (Math.random() > 0.15) return;
  for (let i = 0; i < 3; i++) {
    sparks.push({ x, y, vx: (Math.random() - 0.5) * 2, vy: side === 't' ? -(1 + Math.random() * 2) : (1 + Math.random() * 2), life: 1 });
  }
}

/* ====== 初始化粒子 ====== */
function initParticles() {
  flows.length = 0; curtains.length = 0; dusts.length = 0; sparks.length = 0;
  for (let i = 0; i < 90; i++) flows.push(new Flow());
  for (const hx of jHoles) { curtains.push(new Curtain('t', hx)); curtains.push(new Curtain('b', hx)); }
  for (let i = 0; i < 22; i++) dusts.push(new Dust());
}

/* ====== 绘制函数 ====== */

function drawBg() {
  c.fillStyle = '#050a15';
  c.fillRect(0, 0, W, H);
  // 细网格
  c.strokeStyle = 'rgba(34,211,238,0.025)';
  c.lineWidth = 0.5;
  const gs = sl(50);
  for (let x = 0; x < W; x += gs) { c.beginPath(); c.moveTo(x, 0); c.lineTo(x, H); c.stroke(); }
  for (let y = 0; y < H; y += gs) { c.beginPath(); c.moveTo(0, y); c.lineTo(W, y); c.stroke(); }
}

function drawDuct() {
  // 外壁(厚壁金属质感)
  const wallGrad = c.createLinearGradient(0, sy(D.nt - D.wt), 0, sy(D.nt));
  wallGrad.addColorStop(0, '#374151');
  wallGrad.addColorStop(0.5, '#6b7280');
  wallGrad.addColorStop(1, '#4b5563');

  // 上壁 - 宽段
  c.fillStyle = '#3f4756';
  c.beginPath();
  c.moveTo(sx(D.il), sy(D.it - D.wt));
  c.lineTo(sx(D.il), sy(D.it));
  c.lineTo(sx(D.cl), sy(D.it));
  c.lineTo(sx(D.cr), sy(D.nt));
  c.lineTo(sx(D.nr), sy(D.nt));
  c.lineTo(sx(D.nr), sy(D.nt - D.wt));
  c.lineTo(sx(D.cr), sy(D.nt - D.wt));
  c.lineTo(sx(D.cl), sy(D.it - D.wt));
  c.closePath();
  c.fill();

  // 上壁高光
  c.strokeStyle = 'rgba(148,163,184,0.3)';
  c.lineWidth = 1;
  c.beginPath();
  c.moveTo(sx(D.il), sy(D.it - D.wt + 1));
  c.lineTo(sx(D.cl), sy(D.it - D.wt + 1));
  c.lineTo(sx(D.cr), sy(D.nt - D.wt + 1));
  c.lineTo(sx(D.nr), sy(D.nt - D.wt + 1));
  c.stroke();

  // 下壁
  c.fillStyle = '#3f4756';
  c.beginPath();
  c.moveTo(sx(D.il), sy(D.ib));
  c.lineTo(sx(D.il), sy(D.ib + D.wt));
  c.lineTo(sx(D.cl), sy(D.ib + D.wt));
  c.lineTo(sx(D.cr), sy(D.nb + D.wt));
  c.lineTo(sx(D.nr), sy(D.nb + D.wt));
  c.lineTo(sx(D.nr), sy(D.nb));
  c.lineTo(sx(D.cr), sy(D.nb));
  c.lineTo(sx(D.cl), sy(D.ib));
  c.closePath();
  c.fill();

  // 下壁高光
  c.strokeStyle = 'rgba(148,163,184,0.2)';
  c.beginPath();
  c.moveTo(sx(D.nr), sy(D.nb + D.wt - 1));
  c.lineTo(sx(D.cr), sy(D.nb + D.wt - 1));
  c.lineTo(sx(D.cl), sy(D.ib + D.wt - 1));
  c.lineTo(sx(D.il), sy(D.ib + D.wt - 1));
  c.stroke();

  // 内壁线
  c.strokeStyle = 'rgba(148,163,184,0.35)';
  c.lineWidth = 1;
  c.beginPath();
  c.moveTo(sx(D.il), sy(D.it));
  c.lineTo(sx(D.cl), sy(D.it));
  c.quadraticCurveTo(sx(D.cl + 40), sy(D.it), sx(D.cr), sy(D.nt));
  c.lineTo(sx(D.nr), sy(D.nt));
  c.stroke();
  c.beginPath();
  c.moveTo(sx(D.il), sy(D.ib));
  c.lineTo(sx(D.cl), sy(D.ib));
  c.quadraticCurveTo(sx(D.cl + 40), sy(D.ib), sx(D.cr), sy(D.nb));
  c.lineTo(sx(D.nr), sy(D.nb));
  c.stroke();

  // 射流孔
  for (const hx of jHoles) {
    // 上壁孔
    c.fillStyle = '#22d3ee';
    c.shadowColor = '#22d3ee';
    c.shadowBlur = sl(6) * cPressure;
    c.beginPath();
    c.arc(sx(hx), sy(D.nt), sl(3), 0, Math.PI * 2);
    c.fill();
    // 下壁孔
    c.beginPath();
    c.arc(sx(hx), sy(D.nb), sl(3), 0, Math.PI * 2);
    c.fill();
  }
  c.shadowBlur = 0;

  // 入口/出口箭头区域标记
  c.fillStyle = 'rgba(148,163,184,0.12)';
  c.font = `${sl(11)}px 'IBM Plex Mono'`;
  c.textAlign = 'center';
  c.fillText('入口', sx(D.il + 30), sy(D.it - D.wt - 10));
  c.fillText('出口', sx(D.nr - 30), sy(D.nt - D.wt - 10));
}

function drawCurtainGlow() {
  if (cPressure < 0.05) return;
  const depth = (D.nb - D.nt) * 0.14 * cPressure;
  const alpha = cPressure * 0.35;

  // 上气幕发光区
  const gTop = c.createLinearGradient(0, sy(D.nt), 0, sy(D.nt + depth));
  gTop.addColorStop(0, `rgba(34,211,238,${alpha})`);
  gTop.addColorStop(0.6, `rgba(34,211,238,${alpha * 0.4})`);
  gTop.addColorStop(1, 'rgba(34,211,238,0)');
  c.fillStyle = gTop;
  c.fillRect(sx(D.nl), sy(D.nt), sx(D.nr - D.nl), sy(depth));

  // 下气幕发光区
  const gBot = c.createLinearGradient(0, sy(D.nb), 0, sy(D.nb - depth));
  gBot.addColorStop(0, `rgba(34,211,238,${alpha})`);
  gBot.addColorStop(0.6, `rgba(34,211,238,${alpha * 0.4})`);
  gBot.addColorStop(1, 'rgba(34,211,238,0)');
  c.fillStyle = gBot;
  c.fillRect(sx(D.nl), sy(D.nb - depth), sx(D.nr - D.nl), sy(depth));

  // 脉冲光环
  const pulse = 0.5 + 0.5 * Math.sin(t * 3);
  c.strokeStyle = `rgba(34,211,238,${0.08 * cPressure * pulse})`;
  c.lineWidth = sl(2);
  c.beginPath();
  c.moveTo(sx(D.nl), sy(D.nt + depth));
  c.lineTo(sx(D.nr), sy(D.nt + depth));
  c.stroke();
  c.beginPath();
  c.moveTo(sx(D.nl), sy(D.nb - depth));
  c.lineTo(sx(D.nr), sy(D.nb - depth));
  c.stroke();
}

function drawParticles() {
  // 主气流
  for (const p of flows) {
    c.fillStyle = `rgba(251,146,60,0.75)`;
    c.shadowColor = '#fb923c';
    c.shadowBlur = sl(4);
    c.beginPath();
    c.arc(sx(p.x), sy(p.y), sl(p.sz), 0, Math.PI * 2);
    c.fill();
  }
  c.shadowBlur = 0;

  // 气幕射流
  for (const p of curtains) {
    const a = p.life * 0.85 * cPressure;
    c.fillStyle = `rgba(34,211,238,${a})`;
    c.shadowColor = '#22d3ee';
    c.shadowBlur = sl(5) * cPressure;
    c.beginPath();
    c.arc(sx(p.x), sy(p.y), sl(p.sz), 0, Math.PI * 2);
    c.fill();
  }
  c.shadowBlur = 0;

  // 粉尘
  for (const p of dusts) {
    const a = p.dep ? Math.max(0, 1 - p.depT / 3) * 0.9 : 0.9;
    c.fillStyle = `rgba(217,119,6,${a})`;
    c.shadowColor = '#d97706';
    c.shadowBlur = sl(3);
    c.beginPath();
    c.arc(sx(p.x), sy(p.y), sl(p.sz), 0, Math.PI * 2);
    c.fill();
    if (!p.dep) {
      // 粉尘轨迹
      c.strokeStyle = `rgba(217,119,6,${a * 0.25})`;
      c.lineWidth = sl(0.8);
      c.beginPath();
      c.moveTo(sx(p.x), sy(p.y));
      c.lineTo(sx(p.x - p.vx * 8), sy(p.y - p.vy * 8));
      c.stroke();
    }
  }
  c.shadowBlur = 0;

  // 偏转火花
  for (const s of sparks) {
    const a = s.life * 0.8;
    c.fillStyle = `rgba(34,211,238,${a})`;
    c.beginPath();
    c.arc(sx(s.x), sy(s.y), sl(1.2), 0, Math.PI * 2);
    c.fill();
  }
}

function drawExternalComponents() {
  // 麦克风阵列(上方)
  const micY = D.nt - D.wt - 45;
  c.fillStyle = '#64748b';
  c.font = `500 ${sl(10)}px 'IBM Plex Mono'`;
  c.textAlign = 'center';
  c.fillText('麦克风阵列', sx((D.nl + D.nr) / 2), sy(micY - 18));

  for (let i = 0; i < 5; i++) {
    const mx = D.nl + 80 + i * 150;
    // 麦克风图标
    c.fillStyle = '#94a3b8';
    c.beginPath();
    c.arc(sx(mx), sy(micY), sl(5), 0, Math.PI * 2);
    c.fill();
    c.fillStyle = '#475569';
    c.beginPath();
    c.arc(sx(mx), sy(micY), sl(3), 0, Math.PI * 2);
    c.fill();
    // 连接线到壁面
    c.strokeStyle = 'rgba(148,163,184,0.15)';
    c.lineWidth = 1;
    c.setLineDash([3, 4]);
    c.beginPath();
    c.moveTo(sx(mx), sy(micY + 5));
    c.lineTo(sx(mx), sy(D.nt - D.wt));
    c.stroke();
    c.setLineDash([]);
    // 采集波纹
    const waveR = sl(8 + Math.sin(t * 4 + i) * 3);
    c.strokeStyle = `rgba(248,113,113,${0.25 + 0.15 * Math.sin(t * 4 + i)})`;
    c.lineWidth = 1;
    c.beginPath();
    c.arc(sx(mx), sy(micY), waveR, 0, Math.PI * 2);
    c.stroke();
  }

  // 反相声波发生器(下方)
  const spkY = D.nb + D.wt + 55;
  c.fillStyle = '#64748b';
  c.font = `500 ${sl(10)}px 'IBM Plex Mono'`;
  c.textAlign = 'center';
  c.fillText('反相声波发生器', sx((D.nl + D.nr) / 2), sy(spkY + 25));

  // 控制器
  const ctrlX = (D.nl + D.nr) / 2;
  const ctrlY = spkY;
  c.fillStyle = '#1e293b';
  c.strokeStyle = '#4ade80';
  c.lineWidth = 1.5;
  const cw = sl(70), ch = sl(28);
  c.fillRect(sx(ctrlX) - cw / 2, sy(ctrlY) - ch / 2, cw, ch);
  c.strokeRect(sx(ctrlX) - cw / 2, sy(ctrlY) - ch / 2, cw, ch);
  c.fillStyle = '#4ade80';
  c.font = `500 ${sl(9)}px 'IBM Plex Mono'`;
  c.fillText('ANC 控制器', sx(ctrlX), sy(ctrlY + 3));

  // 延迟标注
  c.fillStyle = '#475569';
  c.font = `300 ${sl(8)}px 'IBM Plex Mono'`;
  c.fillText('延迟 < 1ms', sx(ctrlX), sy(ctrlY + 18));

  // 扬声器
  for (let i = 0; i < 3; i++) {
    const spx = D.nl + 180 + i * 220;
    c.fillStyle = '#4ade80';
    c.shadowColor = '#4ade80';
    c.shadowBlur = sl(4) * ancGain;
    // 三角形扬声器
    c.beginPath();
    c.moveTo(sx(spx - 8), sy(spkY - 6));
    c.lineTo(sx(spx + 8), sy(spkY));
    c.lineTo(sx(spx - 8), sy(spkY + 6));
    c.closePath();
    c.fill();
    c.shadowBlur = 0;

    // 反相声波弧线
    for (let j = 1; j <= 3; j++) {
      const r = sl(10 + j * 10 + Math.sin(t * 5 + i + j) * 3);
      const a = ancGain * (0.4 - j * 0.1);
      c.strokeStyle = `rgba(74,222,128,${Math.max(0, a)})`;
      c.lineWidth = sl(1.2);
      c.beginPath();
      c.arc(sx(spx + 8), sy(spkY), r, -0.5, 0.5);
      c.stroke();
    }
  }

  // 噪声波弧线(从壁面发出向上)
  for (let i = 0; i < 4; i++) {
    const nx = D.nl + 150 + i * 200;
    const noiseAmp = (1 - cPressure * 0.4);
    for (let j = 1; j <= 3; j++) {
      const r = sl(8 + j * 9 + Math.sin(t * 4.5 + i * 0.7 + j) * 3);
      const a = noiseAmp * (0.3 - j * 0.08);
      c.strokeStyle = `rgba(248,113,113,${Math.max(0, a)})`;
      c.lineWidth = sl(1);
      c.beginPath();
      c.arc(sx(nx), sy(D.nt - D.wt), r, Math.PI + 0.4, Math.PI * 2 - 0.4);
      c.stroke();
    }
  }
}

function drawWavePanel() {
  // 波形分析面板
  const px = 1150, py = 170, pw = 230, ph = 380;
  // 面板背景
  c.fillStyle = 'rgba(15,23,42,0.85)';
  c.strokeStyle = 'rgba(34,211,238,0.15)';
  c.lineWidth = 1;
  c.beginPath();
  c.roundRect(sx(px), sy(py), sx(pw), sy(ph), sl(6));
  c.fill();
  c.stroke();

  // 标题
  c.fillStyle = '#22d3ee';
  c.font = `700 ${sl(12)}px 'Rajdhani'`;
  c.textAlign = 'center';
  c.fillText('声波相消分析', sx(px + pw / 2), sy(py + 22));

  const noiseAmp = 1 - cPressure * 0.35;
  const antiAmp = noiseAmp * ancGain;
  const resultAmp = Math.abs(noiseAmp - antiAmp);

  const sections = [
    { label: '残余噪声', color: '#f87171', amp: noiseAmp, yOff: 50 },
    { label: '反相声波', color: '#4ade80', amp: antiAmp, yOff: 160 },
    { label: '叠加结果', color: '#e2e8f0', amp: resultAmp, yOff: 270 },
  ];

  for (const sec of sections) {
    const secY = py + sec.yOff;
    // 标签
    c.fillStyle = '#64748b';
    c.font = `400 ${sl(9)}px 'IBM Plex Mono'`;
    c.textAlign = 'left';
    c.fillText(sec.label, sx(px + 12), sy(secY));
    // 幅值
    c.fillStyle = sec.color;
    c.font = `500 ${sl(9)}px 'IBM Plex Mono'`;
    c.textAlign = 'right';
    c.fillText((sec.amp * 100).toFixed(0) + '%', sx(px + pw - 12), sy(secY));

    // 波形
    const waveY = secY + 30;
    const waveH = 35;
    c.strokeStyle = sec.color;
    c.lineWidth = sl(1.5);
    c.shadowColor = sec.color;
    c.shadowBlur = sec.amp > 0.1 ? sl(4) : 0;
    c.beginPath();
    for (let i = 0; i <= pw - 24; i++) {
      const x = px + 12 + i;
      const phase = t * 4 + i * 0.06;
      const y = waveY + waveH / 2 + Math.sin(phase) * waveH / 2 * sec.amp;
      if (i === 0) c.moveTo(sx(x), sy(y));
      else c.lineTo(sx(x), sy(y));
    }
    c.stroke();
    c.shadowBlur = 0;

    // 中线
    c.strokeStyle = 'rgba(100,116,139,0.15)';
    c.lineWidth = 0.5;
    c.beginPath();
    c.moveTo(sx(px + 12), sy(waveY + waveH / 2));
    c.lineTo(sx(px + pw - 12), sy(waveY + waveH / 2));
    c.stroke();
  }

  // IFR 效果指示
  const ifrY = py + ph - 38;
  const ifrScore = Math.max(0, (1 - resultAmp) * 100);
  c.fillStyle = ifrScore > 80 ? '#22d3ee' : ifrScore > 50 ? '#fbbf24' : '#f87171';
  c.font = `700 ${sl(14)}px 'Rajdhani'`;
  c.textAlign = 'center';
  c.fillText(`IFR 达成度 ${ifrScore.toFixed(0)}%`, sx(px + pw / 2), sy(ifrY));
  // 进度条
  const barW = pw - 30, barH = 6;
  c.fillStyle = '#1e293b';
  c.fillRect(sx(px + 15), sy(ifrY + 8), sx(barW), sy(barH));
  c.fillStyle = ifrScore > 80 ? '#22d3ee' : ifrScore > 50 ? '#fbbf24' : '#f87171';
  c.fillRect(sx(px + 15), sy(ifrY + 8), sx(barW * ifrScore / 100), sy(barH));
}

function drawLabels() {
  c.textAlign = 'center';

  // 收缩段标注
  c.fillStyle = 'rgba(148,163,184,0.4)';
  c.font = `300 ${sl(9)}px 'IBM Plex Mono'`;
  c.fillText('收缩段', sx((D.cl + D.cr) / 2), sy(D.it - D.wt - 28));

  // 窄段标注
  c.fillText('窄风道', sx((D.nl + D.nr) / 2), sy(D.nb + D.wt + 85 + 38));

  // 气幕层标注
  if (cPressure > 0.2) {
    const depth = (D.nb - D.nt) * 0.14 * cPressure;
    c.fillStyle = `rgba(34,211,238,${0.5 * cPressure})`;
    c.font = `500 ${sl(9)}px 'IBM Plex Mono'`;
    c.save();
    c.translate(sx(D.nr + 22), sy(D.nt + depth / 2));
    c.rotate(-Math.PI / 2);
    c.fillText('气幕隔离层', 0, 0);
    c.restore();

    c.save();
    c.translate(sx(D.nr + 22), sy(D.nb - depth / 2));
    c.rotate(-Math.PI / 2);
    c.fillText('气幕隔离层', 0, 0);
    c.restore();
  }

  // 主流区标注
  c.fillStyle = 'rgba(251,146,60,0.4)';
  c.font = `300 ${sl(9)}px 'IBM Plex Mono'`;
  c.fillText('湍流核心区', sx((D.nl + D.nr) / 2), sy((D.nt + D.nb) / 2));

  // 射流角度标注
  if (cPressure > 0.3) {
    const ax = D.nl + 60;
    c.strokeStyle = 'rgba(34,211,238,0.3)';
    c.lineWidth = 1;
    c.setLineDash([2, 3]);
    // 上壁
    c.beginPath();
    c.moveTo(sx(ax), sy(D.nt));
    c.lineTo(sx(ax + 40), sy(D.nt));
    c.stroke();
    c.beginPath();
    c.moveTo(sx(ax), sy(D.nt));
    c.lineTo(sx(ax + 40 * Math.cos(jetAngle)), sy(D.nt + 40 * Math.sin(jetAngle)));
    c.stroke();
    c.setLineDash([]);
    // 角度弧
    c.strokeStyle = 'rgba(34,211,238,0.35)';
    c.beginPath();
    c.arc(sx(ax), sy(D.nt), sl(18), 0, jetAngle);
    c.stroke();
    c.fillStyle = 'rgba(34,211,238,0.5)';
    c.font = `300 ${sl(8)}px 'IBM Plex Mono'`;
    c.textAlign = 'left';
    c.fillText('15°', sx(ax + 20), sy(D.nt + 14));
  }

  // 粉尘偏转标注
  c.fillStyle = 'rgba(217,119,6,0.45)';
  c.font = `300 ${sl(8)}px 'IBM Plex Mono'`;
  c.textAlign = 'center';
  c.fillText('粉尘偏转', sx(D.nl + 200), sy(D.nb - (D.nb - D.nt) * 0.14 * cPressure - 12));

  // 方向箭头 - 主气流
  c.strokeStyle = 'rgba(251,146,60,0.3)';
  c.lineWidth = sl(1.5);
  const arY = (D.nt + D.nb) / 2;
  for (let i = 0; i < 3; i++) {
    const arX = D.il + 20 + i * 60;
    c.beginPath();
    c.moveTo(sx(arX), sy(arY));
    c.lineTo(sx(arX + 20), sy(arY));
    c.lineTo(sx(arX + 15), sy(arY - 5));
    c.moveTo(sx(arX + 20), sy(arY));
    c.lineTo(sx(arX + 15), sy(arY + 5));
    c.stroke();
  }
}

function drawFlowArrows() {
  // 大气流动方向箭头(入口左侧)
  c.strokeStyle = 'rgba(251,146,60,0.2)';
  c.lineWidth = sl(2);
  const ay = (D.it + D.ib) / 2;
  c.beginPath();
  c.moveTo(sx(30), sy(ay));
  c.lineTo(sx(D.il - 5), sy(ay));
  c.stroke();
  c.beginPath();
  c.moveTo(sx(D.il - 5), sy(ay));
  c.lineTo(sx(D.il - 12), sy(ay - 8));
  c.moveTo(sx(D.il - 5), sy(ay));
  c.lineTo(sx(D.il - 12), sy(ay + 8));
  c.stroke();
  c.fillStyle = 'rgba(251,146,60,0.25)';
  c.font = `300 ${sl(9)}px 'IBM Plex Mono'`;
  c.textAlign = 'center';
  c.fillText('主气流', sx(48), sy(ay - 12));

  // 出口箭头
  c.strokeStyle = 'rgba(148,163,184,0.15)';
  c.beginPath();
  c.moveTo(sx(D.nr + 5), sy(ay));
  c.lineTo(sx(D.nr + 40), sy(ay));
  c.stroke();
  c.beginPath();
  c.moveTo(sx(D.nr + 40), sy(ay));
  c.lineTo(sx(D.nr + 33), sy(ay - 7));
  c.moveTo(sx(D.nr + 40), sy(ay));
  c.lineTo(sx(D.nr + 33), sy(ay + 7));
  c.stroke();
}

/* ====== 主动画循环 ====== */
let lastTime = 0;

function animate(now) {
  const dt = Math.min((now - lastTime) / 1000, 0.05);
  lastTime = now;
  t += dt;

  // 更新粒子
  for (const p of flows) p.update(dt);
  for (const p of curtains) p.update(dt);
  for (const p of dusts) p.update(dt);
  // 火花更新
  for (let i = sparks.length - 1; i >= 0; i--) {
    const s = sparks[i];
    s.x += s.vx * dt * 60;
    s.y += s.vy * dt * 60;
    s.life -= dt * 3.5;
    if (s.life <= 0) sparks.splice(i, 1);
  }

  // 绘制
  drawBg();
  drawFlowArrows();
  drawCurtainGlow();
  drawParticles();
  drawDuct();
  drawExternalComponents();
  drawWavePanel();
  drawLabels();

  requestAnimationFrame(animate);
}

/* ====== 初始化与自动播放 ====== */
function init() {
  resize();
  initParticles();
  lastTime = performance.now();
  requestAnimationFrame(animate);
}

// 页面加载后自动播放,重开即播
if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', init);
} else {
  init();
}
window.addEventListener('load', init);
</script>
</body>
</html>

这个动画实现了以下核心内容:

视觉化原理展示(IFR 导向):

  1. 气幕隔离层 — 青色(cyan)发光带沿窄风道内壁流动,清晰呈现"气垫扫帚"效应:将湍流主气流与壁面物理隔离,从源头消除摩擦噪声产生条件,同时阻止粉尘接触壁面沉积。射流孔以 15° 顺流倾角喷射,带有角度标注。

  2. 粉尘偏转效果 — 棕色粉尘颗粒在靠近壁面时被气幕屏障弹回核心区,碰撞瞬间产生青色火花,直观展示防堵机制。当气幕压力降至极低时,粉尘才会沉积到壁面。

  3. 有源降噪相消 — 右侧波形面板实时展示三组波形:残余噪声(红)、反相声波(绿)、叠加结果(白),通过振幅变化清晰呈现声波相消原理。外部麦克风阵列和反相发生器以图标 + 波纹动画呈现。

交互设计:

  • 气幕压力滑块 — 调节气幕强度,实时影响隔离层厚度、粉尘偏转效果和噪声振幅
  • 降噪增益滑块 — 调节 ANC 强度,实时影响反相声波振幅和叠加结果
  • IFR 达成度指标 — 右下角综合评分,两项参数均拉满时达到最高理想解状态

自动播放 — 页面加载后动画立即自动运行,所有粒子系统和波形持续循环,无需手动触发。

积分规则:第一轮对话扣减8分,后续每轮扣6分