鱼C论坛

 找回密码
 立即注册
查看: 149|回复: 13

[作品展示] 浮力模拟器

[复制链接]
发表于 7 天前 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能^_^

您需要 登录 才可以下载或查看,没有账号?立即注册

x
浮力模拟器
3a1b9fee3e683fbb708c87ce8c19af62.png

1.可以通过调节质量和体积,查看方块在水中的状态(浮起或沉入水底)
2.可以切换不同的材质,观察不同材质不同体积时,在水中的受力
3.初始化为100L水,可实时观察水位高度

1(1).gif




  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4.   <meta charset="utf-8" />
  5.   <meta name="viewport" content="width=device-width, initial-scale=1" />
  6.   <title>浮力模拟</title>
  7.   <style>
  8.     :root{
  9.       /* 页面浅绿色背景 */
  10.       --bg-page: linear-gradient(#eaffea 0%, #e3ffe3 50%, #dbffdb 100%);
  11.       --panel-bg: rgba(255,255,255,0.92);
  12.       --panel-border: #d5d7de;
  13.       --text:#1f2937;
  14.       --muted:#6b7280;

  15.       /* 水体为蓝色 */
  16.       --water-top:#5aa6ff;
  17.       --water-bottom:#386bcb;

  18.       /* 默认方块颜色(未选材质时) */
  19.       --cube:#b97a42;
  20.       --cube-edge:#7b4f24;
  21.     }
  22.     *{box-sizing:border-box}
  23.     html,body{height:100%}
  24.     body{
  25.       margin:0;
  26.       font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;
  27.       color:var(--text);
  28.       background: var(--bg-page);
  29.       display:flex;
  30.       flex-direction:column;
  31.       gap:12px;
  32.     }

  33.     header{
  34.       padding:10px 16px;
  35.       display:flex;
  36.       align-items:center;
  37.       justify-content:space-between;
  38.       gap:12px;
  39.     }
  40.     header h1{margin:0; font-size:18px}
  41.     .mini{font-size:12px;color:var(--muted)}

  42.     .app{
  43.       display:grid;
  44.       grid-template-columns: 1.25fr 0.85fr;
  45.       gap:16px;
  46.       width:100%;
  47.       max-width:1200px;
  48.       padding:0 12px 16px;
  49.       margin:0 auto;
  50.     }
  51.     @media (max-width: 900px){
  52.       .app{grid-template-columns: 1fr}
  53.     }

  54.     /* 池子画布区域:池子居中 */
  55.     .stage-wrap{
  56.       position:relative;
  57.       width:100%;
  58.       aspect-ratio: 16 / 9;
  59.       border-radius:12px;
  60.       overflow:hidden;
  61.       box-shadow:0 10px 28px rgba(0,0,0,.14);
  62.       background: radial-gradient(900px 280px at 50% 30%, rgba(255,255,255,.4), transparent 60%), var(--bg-page);
  63.       display:flex;
  64.       align-items:center; justify-content:center;
  65.     }
  66.     canvas{width:100%; height:100%; display:block}

  67.     /* 控制面板 */
  68.     .panel{
  69.       background:var(--panel-bg);
  70.       border:1px solid var(--panel-border);
  71.       border-radius:12px;
  72.       padding:12px;
  73.       box-shadow:0 4px 18px rgba(0,0,0,.10);
  74.     }
  75.     .panel h2{margin:6px 0 12px; font-size:16px}
  76.     .row{display:flex; align-items:center; gap:10px; margin:8px 0}
  77.     .row label{min-width:64px; font-size:14px}
  78.     .row .value{width:110px; text-align:right; font-variant-numeric: tabular-nums}
  79.     .row input[type="range"]{flex:1}
  80.     .inline{display:flex; gap:8px; align-items:center; flex-wrap:wrap}
  81.     .btn-line{display:flex; gap:8px; margin-top:8px; flex-wrap:wrap}
  82.     button{
  83.       appearance:none; border:1px solid var(--panel-border); background:white;
  84.       color:var(--text); border-radius:8px; padding:8px 12px; cursor:pointer
  85.     }
  86.     button:hover{border-color:#c0c4cf}
  87.     .card{
  88.       margin-top:12px; padding:10px; border:1px dashed #cfd3dd; border-radius:10px; background:white
  89.     }
  90.     .mono{font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;}
  91.     .good{color:#16a34a}
  92.     .bad{color:#ef4444}
  93.   </style>
  94. </head>
  95. <body>
  96.   <header>
  97.     <h1>浮力模拟</h1>
  98.     <div class="mini">规则:m=ρ×V;体积≤10 L,质量上限=ρ×10 L;</div>
  99.   </header>

  100.   <main class="app">
  101.     <section class="stage-wrap">
  102.       <canvas id="stage" aria-label="交互画布"></canvas>
  103.     </section>

  104.     <aside class="panel" id="panel">
  105.       <h2>控制面板</h2>

  106.       <div class="row">
  107.         <label for="volume">体积</label>
  108.         <input id="volume" type="range" step="0.01">
  109.         <div class="value"><span id="volumeOut">—</span> L</div>
  110.       </div>

  111.       <div class="row">
  112.         <label for="mass">质量</label>
  113.         <input id="mass" type="range" step="0.01">
  114.         <div class="value"><span id="massOut">—</span> kg</div>
  115.       </div>

  116.       <div class="row inline">
  117.         <label>密度</label>
  118.         <div class="value" style="width:auto"><span id="rhoOut">—</span> kg/m&#179;</div>
  119.         <span id="floatStatus" class="mini">—</span>
  120.       </div>

  121.       <div class="row inline">
  122.         <label for="preset">材质</label>
  123.         <select id="preset">
  124.           <option value="">无(自由模式)</option>
  125.           <option value="wood" selected>木头 ~ 600</option>
  126.           <option value="ice">冰 ~ 920</option>
  127.           <option value="glass">玻璃 ~ 2500</option>
  128.           <option value="al">铝 ~ 2700</option>
  129.           <option value="fe">钢铁 ~ 7850</option>
  130.           <option value="cu">铜 ~ 8900</option>
  131.         </select>
  132.         <label class="mini inline" style="gap:6px">
  133.           <input id="lockRho" type="checkbox" checked>
  134.           锁定密度
  135.         </label>
  136.       </div>

  137.       <div class="btn-line">
  138.         <button id="reset">重置</button>
  139.       </div>

  140.       <div class="card mono" id="readout">计算中…</div>
  141.     </aside>
  142.   </main>

  143.   <script>
  144.   // ================= 常量与单位(SI) =================
  145.   const RHO_WATER = 1000.0; // kg/m&#179;
  146.   const G = 9.81;           // m/s&#178;
  147.   const W =0.8, D = 0.5, H = 1.0; // 容器长宽高 m
  148.   const A = W * D;                      // 底面积 m&#178;
  149.   const V_WATER0 = 0.1;                 // 初始水量 0.1 m&#179; = 100 L
  150.   const EPS = 1e-9;
  151.   const MASS_EPS = 1e-9;                // “无方块”质量阈值
  152.   const VOLUME_ZERO_EPS_L = 1e-6;       // 升,体积≈0 的阈值

  153.   // 控制水位线标签的垂直偏移(像素,数值越大越高)
  154.   const HUD_LABEL_OFFSET_PX = 16;

  155.   // 方块几何限制
  156.   const S_MIN = 0.05;
  157.   const S_MAX = Math.min(W, D) * 0.95;
  158.   const V_MIN_L = 0.00;                 // 体积最小值改为 0 L
  159.   const V_MAX_L_HARD = 10.0;            // 体积上限:10 L
  160.   const V_MAX_M3 = V_MAX_L_HARD / 1000.0;

  161.   // 材质密度与颜色(铜色为 RGB(184,115,51))
  162.   const PRESET_RHO = {
  163.     wood: 600, ice: 920, glass: 2500, al: 2700, fe: 7850, cu: 8900
  164.   };
  165.   const PRESET_COLOR = {
  166.     wood: { fill:'#8d6e63', edge:'#5d4037' },      // 木
  167.     ice:  { fill:'rgba(188,235,255,0.75)', edge:'#90cdf4' }, // 冰
  168.     glass:{ fill:'rgba(200,220,240,0.45)', edge:'#93b4d6' }, // 玻璃
  169.     al:   { fill:'#c7c9cc', edge:'#8e8f93' },      // 铝
  170.     fe:   { fill:'#5f6672', edge:'#3f444c' },      // 钢铁
  171.     cu:   { fill:'rgb(184,115,51)', edge:'rgb(124,78,35)' }, // 铜
  172.     default: { fill:getCss('--cube'), edge:getCss('--cube-edge') }
  173.   };

  174.   // ================ DOM ================
  175.   const canvas = document.getElementById('stage');
  176.   const ctx = canvas.getContext('2d');

  177.   const elVolume = document.getElementById('volume');
  178.   const elMass = document.getElementById('mass');
  179.   const outVolume = document.getElementById('volumeOut');
  180.   const outMass = document.getElementById('massOut');
  181.   const outRho = document.getElementById('rhoOut');
  182.   const outFloatStatus = document.getElementById('floatStatus');
  183.   const readout = document.getElementById('readout');
  184.   const presetSel = document.getElementById('preset');
  185.   const lockRho = document.getElementById('lockRho');
  186.   const btnReset = document.getElementById('reset');

  187.   // 初始化滑块范围
  188.   elVolume.min = V_MIN_L.toFixed(2);
  189.   elVolume.max = V_MAX_L_HARD.toFixed(2);
  190.   elVolume.value = "5.00"; // 默认 5 L

  191.   // ================ 工具 ================
  192.   function getCss(name){ return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
  193.   function clamp(v,a,b){ return Math.max(a, Math.min(b, v)); }
  194.   const L_to_m3 = L => L / 1000.0;
  195.   const m3_to_L = m3 => m3 * 1000.0;
  196.   const fmt = (x,n=2) => (+x).toFixed(n);

  197.   function activeRho(){
  198.     const key = presetSel.value;
  199.     return key ? PRESET_RHO[key] : null;
  200.   }
  201.   function ensureMassRange(){
  202.     const rho = activeRho();
  203.     if (rho){
  204.       elMass.min = "0.00";
  205.       elMass.max = (rho * V_MAX_M3).toFixed(2); // ρ × 10 L
  206.     }else{
  207.       elMass.min = "0.00";
  208.       elMass.max = "200.00"; // 自由模式下给一个宽松上限
  209.     }
  210.   }
  211.   function isCubeActive(){
  212.     return state.mass > MASS_EPS && state.volumeL > VOLUME_ZERO_EPS_L;
  213.   }

  214.   // ================ 物理求解(静态平衡) ================
  215.   function solveEquilibrium(mass, side){
  216.     const s = side, m = mass, V_cube = s**3;
  217.     const canFloatByDensity = m <= RHO_WATER * V_cube + EPS;
  218.     if (canFloatByDensity){
  219.       const V_sub = m / RHO_WATER;
  220.       const s2 = s*s || EPS; // 防护
  221.       const h_sub = V_sub / s2;
  222.       const h_pre = (V_WATER0 + V_sub)/A;
  223.       const h = Math.min(h_pre, H);
  224.       const y_bottom_est = h - h_sub;
  225.       if (y_bottom_est >= -EPS){
  226.         const y_bottom = Math.max(0, y_bottom_est);
  227.         const V_water_actual = Math.max(0, A*h - V_sub);
  228.         const overflow = Math.max(0, V_WATER0 - V_water_actual);
  229.         return {mode:'浮起', h, V_cube, V_sub, h_sub, y_bottom,
  230.                 overflow, F_b:RHO_WATER*G*V_sub, W_force:m*G, N:0};
  231.       }
  232.     }
  233.     let A_minus = A - s*s; if (A_minus<=0) A_minus = EPS;
  234.     const h_partial_pre = V_WATER0 / A_minus;
  235.     let V_sub, h, overflow=0, mode;
  236.     if (h_partial_pre < s - EPS){
  237.       h = Math.min(h_partial_pre, H);
  238.       if (h_partial_pre > H + EPS) overflow = Math.max(0, V_WATER0 - H*A_minus);
  239.       V_sub = s*s*h; mode='触底-部分浸没';
  240.     }else{
  241.       V_sub = s**3;
  242.       const h_pre = (V_WATER0 + V_sub)/A;
  243.       h = Math.min(h_pre, H);
  244.       if (h_pre > H + EPS) overflow = Math.max(0, V_WATER0 - (A*H - V_sub));
  245.       mode='触底-完全浸没';
  246.     }
  247.     const F_b = RHO_WATER*G*V_sub, W_force=m*G, N=Math.max(0, W_force-F_b);
  248.     const h_sub = Math.min(s>EPS ? V_sub/(s*s) : 0, s);
  249.     return {mode, h, V_cube, V_sub, h_sub, y_bottom:0, overflow, F_b, W_force, N};
  250.   }

  251.   // 仅水(无方块)解:用于质量=0 或体积=0
  252.   function solveWaterOnly(){
  253.     const h_pre = V_WATER0 / A;
  254.     const h = Math.min(h_pre, H);
  255.     return {
  256.       mode: '无方块',
  257.       h,
  258.       V_cube: 0, V_sub: 0, h_sub: 0,
  259.       y_bottom: 0,
  260.       overflow: Math.max(0, V_WATER0 - A*H),
  261.       F_b: 0, W_force: 0, N: 0
  262.     };
  263.   }

  264.   // ================ 拖拽中的即时置换 =================
  265.   function solveDuringDrag(y_bottom, side, mass){
  266.     const s = side, V_cube = s**3, s2 = s*s;
  267.     const h0 = V_WATER0 / A;             // 未浸没解
  268.     const hFull = (V_WATER0 + V_cube)/A; // 完全浸没解

  269.     let h_pre, d; // 浸没高度(m)
  270.     if (h0 <= y_bottom + EPS){
  271.       d = 0; h_pre = h0; // 未浸没
  272.     }else{
  273.       const A_minus = A - s2;
  274.       if (A_minus <= EPS){
  275.         if (hFull >= y_bottom + s - EPS){ d = s; h_pre = hFull; }
  276.         else { d = Math.max(0, h0 - y_bottom); h_pre = h0; }
  277.       }else{
  278.         const hPart = (V_WATER0 - s2*y_bottom) / A_minus;
  279.         if (hPart > y_bottom + EPS && hPart < y_bottom + s - EPS){
  280.           d = hPart - y_bottom; h_pre = hPart;
  281.         }else if (hPart <= y_bottom + EPS){
  282.           d = 0; h_pre = h0;
  283.         }else{
  284.           d = s; h_pre = hFull;
  285.         }
  286.       }
  287.     }

  288.     const h = Math.min(h_pre, H);
  289.     const V_sub = s2 * d;
  290.     const overflow = Math.max(0, V_WATER0 + V_sub - A*H);

  291.     return {
  292.       mode: '拖拽中',
  293.       h, V_cube, V_sub,
  294.       h_sub: d,
  295.       y_bottom: clamp(y_bottom, 0, H - s),
  296.       overflow,
  297.       F_b: RHO_WATER * G * V_sub,
  298.       W_force: mass * G,
  299.       N: 0
  300.     };
  301.   }

  302.   // ================ 池子居中 ================
  303.   const view = { w:0, h:0, tank:{left:0, top:0, width:0, height:0, scale:1} };

  304.   function resizeCanvas(){
  305.     const rect = canvas.getBoundingClientRect();
  306.     const dpr = Math.max(1, window.devicePixelRatio || 1);
  307.     canvas.width = Math.round(rect.width * dpr);
  308.     canvas.height = Math.round(rect.height * dpr);
  309.     ctx.setTransform(dpr,0,0,dpr,0,0);

  310.     view.w = rect.width; view.h = rect.height;

  311.     const margin = 18;
  312.     const S = Math.min((view.w - margin*2)/W, (view.h - margin*2)/H);
  313.     const tankW = W*S, tankH = H*S;
  314.     view.tank.left = (view.w - tankW)/2;
  315.     view.tank.top = (view.h - tankH)/2;
  316.     view.tank.width = tankW; view.tank.height = tankH; view.tank.scale = S;

  317.     draw();
  318.   }
  319.   function yFromBottom(y_m){
  320.     const t = view.tank.top, S = view.tank.scale;
  321.     return t + (H - y_m) * S;
  322.   }

  323.   // ================ 交互状态(含拖拽) ================
  324.   const state = {
  325.     mass: 0,
  326.     volumeL: parseFloat(elVolume.value),
  327.     s: Math.cbrt(L_to_m3(parseFloat(elVolume.value))),
  328.     sol: null,        // 平衡解
  329.     solDrag: null,    // 拖拽即时解

  330.     cubePx: {x:0,y:0,w:0,h:0},
  331.     cubeTargetPx: {x:0,y:0},
  332.     dragging:false, dragOffset:{x:0,y:0}, animId:null,
  333.     lastInput:'volume'
  334.   };

  335.   // ================ 绘制 ================
  336.   function drawTank(){
  337.     const {left, top, width, height} = view.tank;
  338.     ctx.fillStyle = "#9aa0a6";
  339.     ctx.fillRect(left-8, top-8, width+16, height+16);
  340.     ctx.fillStyle = "#cfd3da";
  341.     ctx.fillRect(left, top, width, height);
  342.     ctx.lineWidth = 2;
  343.     ctx.strokeStyle = "#6a6a6a";
  344.     ctx.strokeRect(left, top, width, height);
  345.   }
  346.   function drawWater(sol){
  347.     const {left} = view.tank;
  348.     const S = view.tank.scale;
  349.     const h = sol.h, yTop = yFromBottom(h), hPx = h*S;
  350.     const grd = ctx.createLinearGradient(0, yTop + hPx, 0, yTop);
  351.     grd.addColorStop(0, getCss('--water-bottom'));
  352.     grd.addColorStop(1, getCss('--water-top'));
  353.     ctx.fillStyle = grd;
  354.     ctx.fillRect(view.tank.left, yTop, view.tank.width, hPx);
  355.     ctx.fillStyle = "rgba(255,255,255,0.35)";
  356.     ctx.fillRect(left, yTop-1.5, view.tank.width, 1.5);
  357.   }
  358.   function equilibriumCubeRect(sol){
  359.     const S = view.tank.scale;
  360.     const s = Math.cbrt(sol.V_cube);
  361.     const wPx = s*S, hPx = s*S;
  362.     const xLeft = view.tank.left + (view.tank.width - wPx)/2; // 目标水平居中
  363.     const yTop = yFromBottom(sol.y_bottom + s);
  364.     return {x:xLeft, y:yTop, w:wPx, h:hPx};
  365.   }
  366.   function drawCube(rect, materialKey){
  367.     const style = PRESET_COLOR[materialKey] || PRESET_COLOR.default;
  368.     ctx.fillStyle = style.fill;
  369.     ctx.strokeStyle = style.edge;
  370.     ctx.lineWidth = 2;
  371.     ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
  372.     ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
  373.     ctx.fillStyle = "rgba(255,255,255,0.14)";
  374.     ctx.fillRect(rect.x, rect.y, rect.w, 6);
  375.     ctx.fillStyle = "rgba(0,0,0,0.06)";
  376.     for(let i=0;i<6;i++){ ctx.fillRect(rect.x + i*(rect.w/6), rect.y, 2, rect.h); }
  377.   }

  378.   // 水位线标签:只显示“总水量(升)”,位置可通过 HUD_LABEL_OFFSET_PX 调高
  379.   function drawHUD(sol){
  380.     const VsL = m3_to_L(sol.V_sub);
  381.     const totalL = 100 + VsL;
  382.     const text = `${fmt(totalL,2)} 升(L)`;
  383.     ctx.font = "12px ui-monospace, monospace";
  384.     const tw = ctx.measureText(text).width + 16, th = 18;

  385.     // 将标签位置比水面更高 HUD_LABEL_OFFSET_PX 像素,并做夹取避免越界
  386.     let y = yFromBottom(sol.h) - th/2 - HUD_LABEL_OFFSET_PX;
  387.     const yMin = view.tank.top + 4;
  388.     const yMax = view.tank.top + view.tank.height - th - 4;
  389.     y = Math.max(yMin, Math.min(y, yMax));

  390.     const x = view.tank.left - tw - 8;
  391.     ctx.fillStyle = "rgba(0,0,0,0.25)"; ctx.fillRect(x, y, tw, th);
  392.     ctx.strokeStyle = "rgba(255,255,255,0.6)"; ctx.strokeRect(x, y, tw, th);
  393.     ctx.fillStyle = "#e5f0ff"; ctx.fillText(text, x+8, y+12);
  394.   }

  395.   function draw(){
  396.     const solActive = state.dragging && state.solDrag ? state.solDrag : state.sol;
  397.     ctx.clearRect(0,0,canvas.width, canvas.height);
  398.     drawTank();
  399.     if (solActive){
  400.       drawWater(solActive);
  401.       drawHUD(solActive);
  402.       if (isCubeActive()){
  403.         drawCube(state.cubePx, presetSel.value);
  404.       }
  405.     }
  406.   }

  407.   // ================ 状态设置 ================
  408.   function setFloatStatusFromSol(sol){
  409.     if (!isCubeActive()){
  410.       outFloatStatus.textContent = "无方块(质量=0 或体积=0)";
  411.       outFloatStatus.className = "mini";
  412.       return;
  413.     }
  414.     const floats = sol.mode === '浮起';
  415.     outFloatStatus.textContent = floats ? "密度低于水 → 漂浮" : "密度高/受限 → 下沉或触底";
  416.     outFloatStatus.className = "mini " + (floats ? "good" : "bad");
  417.   }

  418.   function updateFromControls(source=state.lastInput){
  419.     state.lastInput = source;

  420.     // 读取滑块
  421.     let volumeL = clamp(parseFloat(elVolume.value), V_MIN_L, V_MAX_L_HARD);
  422.     let mass = parseFloat(elMass.value);
  423.     const rhoAct = activeRho();

  424.     // 若体积为 0:强制质量=0(不论什么模式/来源)
  425.     if (volumeL <= VOLUME_ZERO_EPS_L){
  426.       volumeL = 0;
  427.       mass = 0;
  428.       elVolume.value = "0.00";
  429.       elMass.value = "0.00";
  430.     }else{
  431.       // 材质模式:强制锁定密度,体积/质量双向联动
  432.       if (rhoAct){
  433.         lockRho.checked = true;
  434.         lockRho.disabled = true;
  435.         ensureMassRange();

  436.         if (source === 'mass'){
  437.           mass = clamp(mass, parseFloat(elMass.min), parseFloat(elMass.max));
  438.           const V = mass / rhoAct; // m&#179;
  439.           volumeL = clamp(m3_to_L(V), V_MIN_L, V_MAX_L_HARD);
  440.           elVolume.value = volumeL.toFixed(2);
  441.         }else{
  442.           volumeL = clamp(volumeL, V_MIN_L, V_MAX_L_HARD);
  443.           const V = L_to_m3(volumeL);
  444.           mass = clamp(rhoAct * V, parseFloat(elMass.min), parseFloat(elMass.max));
  445.           elMass.value = mass.toFixed(2);
  446.         }
  447.       }else{
  448.         // 自由模式
  449.         lockRho.disabled = false;
  450.         ensureMassRange();
  451.         if (lockRho.checked){
  452.           const rhoLock = getLockedRhoForFreeMode();
  453.           elMass.max = (rhoLock * V_MAX_M3).toFixed(2);
  454.           if (source === 'mass'){
  455.             mass = clamp(mass, 0, parseFloat(elMass.max));
  456.             volumeL = clamp(m3_to_L(mass / rhoLock), V_MIN_L, V_MAX_L_HARD);
  457.             elVolume.value = volumeL.toFixed(2);
  458.           }else{
  459.             const V = L_to_m3(volumeL);
  460.             mass = clamp(rhoLock * V, 0, parseFloat(elMass.max));
  461.             elMass.value = mass.toFixed(2);
  462.           }
  463.         }
  464.       }
  465.     }

  466.     // 写回状态
  467.     state.mass = mass;
  468.     state.volumeL = volumeL;
  469.     state.s = Math.cbrt(L_to_m3(volumeL));

  470.     // 平衡求解与读数(无方块 -> 水体独立解)
  471.     state.sol = isCubeActive()
  472.       ? solveEquilibrium(state.mass, state.s)
  473.       : solveWaterOnly();

  474.     const rhoDisplay = rhoAct ? rhoAct : (state.mass / L_to_m3(state.volumeL));
  475.     outVolume.textContent = fmt(state.volumeL, 2);
  476.     outMass.textContent = fmt(state.mass, 2);
  477.     outRho.textContent = isFinite(rhoDisplay) ? fmt(rhoDisplay, 1) : "—";

  478.     setFloatStatusFromSol(state.sol);
  479.     renderInfoAndPositions(state.sol, /*snapWhenIdle*/ true);
  480.   }

  481.   function getLockedRhoForFreeMode(){
  482.     const shown = parseFloat(outRho.textContent);
  483.     return isFinite(shown) ? shown : 600;
  484.   }

  485.   function renderInfoAndPositions(sol, snapWhenIdle){
  486.     // 文本读数
  487.     if (!isCubeActive()){
  488.       readout.innerHTML =
  489.         `状态: 无方块(质量=0 或体积=0)<br>` +
  490.         `总水量: ${fmt(100.00,2)} L | 水位 h=${fmt(sol.h,3)} m`;
  491.     }else{
  492.       const VsL = m3_to_L(sol.V_sub);
  493.       const totalL = 100 + VsL;
  494.       const VcubeL = m3_to_L(sol.V_cube);
  495.       const frac = (sol.V_sub / sol.V_cube) * 100;
  496.       readout.innerHTML =
  497.         `状态: ${sol.mode}<br>` +
  498.         `总水量: ${fmt(totalL,2)} L | 水位 h=${fmt(sol.h,3)} m<br>` +
  499.         `方块: 体积 ${fmt(VcubeL,1)} L, 边长 s=${fmt(Math.cbrt(sol.V_cube),3)} m, 质量 ${fmt(state.mass,2)} kg<br>` +
  500.         `密度: ${fmt(state.mass / L_to_m3(state.volumeL),1)} kg/m&#179;(水=1000)<br>` +
  501.         `浸没体积: ${fmt(sol.V_sub,4)} m&#179;(${fmt(frac,1)}%)<br>` +
  502.         `受力: 浮力 ${fmt(sol.F_b,1)} N | 重力 ${fmt(sol.W_force,1)} N | 支持力 ${fmt(sol.N,1)} N`;
  503.     }

  504.     // 平衡位置(作为目标)
  505.     if (isCubeActive()){
  506.       const eq = equilibriumCubeRect(state.sol);
  507.       state.cubePx.w = eq.w; state.cubePx.h = eq.h;

  508.       if (snapWhenIdle && !state.dragging && !state.animId){
  509.         state.cubePx.x = eq.x;
  510.         state.cubePx.y = eq.y;
  511.       }
  512.       state.cubeTargetPx.x = eq.x;
  513.       state.cubeTargetPx.y = eq.y;
  514.     }

  515.     draw();
  516.   }

  517.   // ================ 拖拽(即时体积置换 + 松手回归平衡) ================
  518.   function pointInRect(px,py,r){ return px>=r.x && px<=r.x+r.w && py>=r.y && py<=r.y+r.h; }
  519.   function clampCubeInsideTank(rect){
  520.     const Lp = view.tank.left, Tp = view.tank.top, Wp = view.tank.width, Hp = view.tank.height;
  521.     rect.x = clamp(rect.x, Lp, Lp + Wp - rect.w);
  522.     rect.y = clamp(rect.y, Tp, Tp + Hp - rect.h);
  523.   }
  524.   function getCanvasPoint(e){
  525.     const r = canvas.getBoundingClientRect();
  526.     return { x: e.clientX - r.left, y: e.clientY - r.top };
  527.   }
  528.   // 将当前像素y(方块顶边)转换为自底向上的 y_bottom(m)
  529.   function currentYBottomMeters(){
  530.     const S = view.tank.scale, topPx = view.tank.top;
  531.     const yTopPx = state.cubePx.y;
  532.     const s = state.s;
  533.     const yBottom = H - (yTopPx - topPx)/S - s;
  534.     return clamp(yBottom, 0, H - s);
  535.   }

  536.   canvas.addEventListener('pointerdown', (e) => {
  537.     if (!isCubeActive()) return; // 无方块:不可拖拽
  538.     const p = getCanvasPoint(e);
  539.     if (pointInRect(p.x, p.y, state.cubePx)){
  540.       state.dragging = true;
  541.       canvas.setPointerCapture(e.pointerId);
  542.       state.dragOffset.x = p.x - state.cubePx.x;
  543.       state.dragOffset.y = p.y - state.cubePx.y;
  544.       if (state.animId){ cancelAnimationFrame(state.animId); state.animId = null; }
  545.     }
  546.   });

  547.   canvas.addEventListener('pointermove', (e) => {
  548.     if (!state.dragging || !isCubeActive()) return;
  549.     const p = getCanvasPoint(e);
  550.     state.cubePx.x = p.x - state.dragOffset.x;
  551.     state.cubePx.y = p.y - state.dragOffset.y;
  552.     clampCubeInsideTank(state.cubePx);

  553.     // 拖拽时:根据当前 y_bottom 计算水位与浸没体积
  554.     const yb = currentYBottomMeters();
  555.     state.solDrag = solveDuringDrag(yb, state.s, state.mass);

  556.     renderInfoAndPositions(state.solDrag, /*snapWhenIdle*/ false);
  557.   });

  558.   function updateStatusToEquilibrium(){
  559.     // 松开或取消:切回平衡态(无方块 -> 水体独立解)
  560.     state.sol = isCubeActive()
  561.       ? solveEquilibrium(state.mass, state.s)
  562.       : solveWaterOnly();
  563.     state.solDrag = null;
  564.     setFloatStatusFromSol(state.sol);
  565.     renderInfoAndPositions(state.sol, /*snapWhenIdle*/ false);
  566.   }

  567.   canvas.addEventListener('pointerup', (e) => {
  568.     if (!state.dragging) return;
  569.     state.dragging = false;
  570.     canvas.releasePointerCapture(e.pointerId);
  571.     updateStatusToEquilibrium();
  572.     if (isCubeActive()) startReturnAnimation(); // 仅有方块时回弹
  573.   });

  574.   canvas.addEventListener('pointercancel', () => {
  575.     if (state.dragging){
  576.       state.dragging = false;
  577.       updateStatusToEquilibrium();
  578.       if (isCubeActive()) startReturnAnimation();
  579.     }
  580.   });

  581.   function startReturnAnimation(){
  582.     const duration = 450;
  583.     const start = performance.now();
  584.     const y0 = state.cubePx.y;
  585.     const xKeep = state.cubePx.x;
  586.     const yTarget = state.cubeTargetPx.y;

  587.     function step(t){
  588.       const k = clamp((t - start)/duration, 0, 1);
  589.       const e = 1 - Math.pow(1 - k, 3); // easeOutCubic
  590.       state.cubePx.y = y0 + (yTarget - y0) * e;
  591.       state.cubePx.x = xKeep;

  592.       // 动画过程中显示平衡解
  593.       state.solDrag = null;
  594.       draw();

  595.       if (k < 1) state.animId = requestAnimationFrame(step);
  596.       else state.animId = null;
  597.     }
  598.     if (state.animId) cancelAnimationFrame(state.animId);
  599.     state.animId = requestAnimationFrame(step);
  600.   }

  601.   // ================ 记录输入来源 ================
  602.   elVolume.addEventListener('input', () => updateFromControls('volume'));
  603.   elMass.addEventListener('input', () => updateFromControls('mass'));

  604.   presetSel.addEventListener('change', () => {
  605.     const key = presetSel.value;
  606.     if (key){
  607.       lockRho.checked = true;
  608.       lockRho.disabled = true;
  609.     }else{
  610.       lockRho.disabled = false;
  611.     }
  612.     ensureMassRange();
  613.     updateFromControls('volume');
  614.   });

  615.   lockRho.addEventListener('change', () => {
  616.     if (activeRho()){ lockRho.checked = true; lockRho.disabled = true; }
  617.     updateFromControls('volume');
  618.   });

  619.   btnReset.addEventListener('click', () => {
  620.     presetSel.value = "wood"; // 默认木头
  621.     lockRho.checked = true; lockRho.disabled = true;
  622.     elVolume.value = "5.00";
  623.     ensureMassRange();
  624.     updateFromControls('volume');
  625.   });

  626.   window.addEventListener('resize', resizeCanvas);

  627.   
  628.   ensureMassRange();
  629.   resizeCanvas();
  630.   // 初始化质量与状态(按默认材质密度)
  631.   (function init(){
  632.     const rho = activeRho();
  633.     const V = L_to_m3(parseFloat(elVolume.value));
  634.     elMass.value = (rho * V).toFixed(2);
  635.     updateFromControls('volume');
  636.   })();
  637.   </script>
  638. </body>
  639. </html>
复制代码

评分

参与人数 4荣誉 +7 鱼币 +7 贡献 +11 C币 +2 收起 理由
sfqxx + 3
小甲鱼 + 2 + 2 + 2 + 2 无条件支持楼主!
~风介~ + 2 + 2 + 3 鱼C有你更精彩^_^
小甲鱼的二师兄 + 3 + 3 + 3 鱼C有你更精彩^_^

查看全部评分

小甲鱼最新课程 -> https://ilovefishc.com
回复

使用道具 举报

发表于 7 天前 | 显示全部楼层
以下是浮力模拟器的实现要点解析和关键代码示例:

### 核心实现思路
1. **物理模型**:
   - 浮力计算:`F_b = ρ_water * g * V_sub`
   - 重力计算:`W = m * g`
   - 物体状态判断:
     - 漂浮:`ρ_object < ρ_water`
     - 悬浮:`ρ_object = ρ_water`
     - 沉底:`ρ_object > ρ_water`

2. **关键交互功能**:
  1. // 材质选择事件
  2. presetSel.addEventListener('change', () => {
  3.   const material = presetSel.value;
  4.   if(material) {
  5.     // 自动设置密度并锁定
  6.     const density = PRESET_RHO[material];
  7.     lockRho.checked = true;
  8.     updateCubeProperties(density);
  9.   }
  10. });

  11. // 滑块联动逻辑
  12. function updateCubeProperties(targetDensity) {
  13.   const volume = parseFloat(elVolume.value);
  14.   const mass = targetDensity * (volume/1000);
  15.   elMass.value = mass.toFixed(2);
  16.   redrawScene();
  17. }
复制代码


### 物理计算核心代码
  1. function calculateBuoyancy() {
  2.   // 获取输入值
  3.   const volume_m3 = parseFloat(elVolume.value) / 1000;
  4.   const mass = parseFloat(elMass.value);
  5.   
  6.   // 计算密度
  7.   const density = mass / volume_m3;
  8.   
  9.   // 计算浮力状态
  10.   if(density < RHO_WATER) {
  11.     // 漂浮状态
  12.     const submergedRatio = density / RHO_WATER;
  13.     const submergedVolume = volume_m3 * submergedRatio;
  14.     return {
  15.       status: "漂浮",
  16.       submergedVolume: submergedVolume,
  17.       buoyancyForce: RHO_WATER * G * submergedVolume
  18.     };
  19.   } else {
  20.     // 沉底状态
  21.     return {
  22.       status: "沉底",
  23.       submergedVolume: volume_m3,
  24.       buoyancyForce: RHO_WATER * G * volume_m3
  25.     };
  26.   }
  27. }
复制代码


### 水位计算实现
  1. function calculateWaterLevel() {
  2.   const baseArea = W * D; // 容器底面积
  3.   const initialVolume = 0.1; // 100L水
  4.   
  5.   // 获取方块浸没体积
  6.   const { submergedVolume } = calculateBuoyancy();
  7.   
  8.   // 计算新水位高度
  9.   const newWaterLevel = (initialVolume + submergedVolume) / baseArea;
  10.   
  11.   // 限制水位不超过容器高度
  12.   return Math.min(newWaterLevel, H);
  13. }
复制代码


### 使用说明
1. **调节参数**:
   - 通过滑块调整体积(0-10L)和质量(0-200kg)
   - 选择材质自动设置密度
   - 勾选"锁定密度"保持密度恒定

2. **观察效果**:
   - 方块颜色变化表示不同材质
   - 实时水位线显示
   - 控制面板显示浮力/重力数据

3. **交互操作**:
   - 拖拽方块改变位置
   - 点击"重置"恢复初始状态

### 效果展示关键代码
  1. function drawWater() {
  2.   const waterLevel = calculateWaterLevel();
  3.   const canvasHeight = canvas.height;
  4.   
  5.   // 计算水面Y坐标(画布坐标系)
  6.   const waterY = canvasHeight - (waterLevel / H) * canvasHeight;
  7.   
  8.   // 绘制水体渐变
  9.   const gradient = ctx.createLinearGradient(0, waterY, 0, canvasHeight);
  10.   gradient.addColorStop(0, getCss('--water-top'));
  11.   gradient.addColorStop(1, getCss('--water-bottom'));
  12.   
  13.   ctx.fillStyle = gradient;
  14.   ctx.fillRect(0, waterY, canvas.width, canvas.height - waterY);
  15. }
复制代码


[查看完整示例](https://codepen.io/your-demo-link)(实际开发时需替换为真实演示链接)

这个模拟器完整实现了:
1. 阿基米德原理可视化
2. 材质密度实时计算
3. 水位动态变化
4. 物体状态(漂浮/沉底)判断
5. 交互式拖拽体验

通过调节参数,可以直观理解:
- 为什么钢铁轮船能漂浮
- 冰山的水下体积比例
- 密度与浮力的关系等物理概念

以上回复来自 FishC AI 小助理,如未能正确解答您的问题,请继续追问。
小甲鱼最新课程 -> https://ilovefishc.com
回复 支持 反对

使用道具 举报

发表于 7 天前 | 显示全部楼层
@不二如是   2# 与 FishC 有啥区别
小甲鱼最新课程 -> https://ilovefishc.com
回复 支持 1 反对 0

使用道具 举报

发表于 7 天前 | 显示全部楼层
sfqxx 发表于 2025-8-13 19:46
@不二如是   2# 与 FishC 有啥区别

JS祖师爷,专门服务Web板块
FishC是全局
小甲鱼最新课程 -> https://ilovefishc.com
回复 支持 反对

使用道具 举报

发表于 7 天前 | 显示全部楼层
不二如是 发表于 2025-8-13 19:55
JS祖师爷,专门服务Web板块
FishC是全局


3Q
小甲鱼最新课程 -> https://ilovefishc.com
回复 支持 反对

使用道具 举报

发表于 6 天前 | 显示全部楼层
赞啊!!
小甲鱼最新课程 -> https://ilovefishc.com
回复

使用道具 举报

发表于 6 天前 | 显示全部楼层
不错不错
小甲鱼最新课程 -> https://ilovefishc.com
回复 支持 反对

使用道具 举报

发表于 6 天前 | 显示全部楼层
这个匿名鱼油是怎么回事??
小甲鱼最新课程 -> https://ilovefishc.com
回复 支持 反对

使用道具 举报

 楼主| 发表于 6 天前 | 显示全部楼层
sfqxx 发表于 2025-8-13 19:46
@不二如是   2# 与 FishC 有啥区别

你好,他显示我的用户组还没有权限添加好友

评分

参与人数 1贡献 +3 收起 理由
sfqxx + 3

查看全部评分

小甲鱼最新课程 -> https://ilovefishc.com
回复 支持 反对

使用道具 举报

发表于 6 天前 | 显示全部楼层
匿名鱼油 发表于 2025-8-14 18:47
你好,他显示我的用户组还没有权限添加好友

好的,加油升级吧
小甲鱼最新课程 -> https://ilovefishc.com
回复 支持 反对

使用道具 举报

 楼主| 发表于 6 天前 | 显示全部楼层
sfqxx 发表于 2025-8-14 18:49
好的,加油升级吧

小甲鱼最新课程 -> https://ilovefishc.com
回复 支持 反对

使用道具 举报

发表于 6 天前 | 显示全部楼层

哦对了你发的html代码该怎么下载在自己这里运行啊,我不太会
小甲鱼最新课程 -> https://ilovefishc.com
回复 支持 反对

使用道具 举报

 楼主| 发表于 6 天前 | 显示全部楼层
sfqxx 发表于 2025-8-14 18:51
哦对了你发的html代码该怎么下载在自己这里运行啊,我不太会

我使用的是vs code,你可以参考这个视频下载插件
  1. https://www.bilibili.com/video/BV1PL411t7Fy/?spm_id_from=333.337.search-card.all.click&vd_source=451f1e8f04d8c94807752b187ccd4c7e
复制代码

也可以直接将代码保存为一个html文件,然后拖入浏览器打开就可以了
小甲鱼最新课程 -> https://ilovefishc.com
回复 支持 反对

使用道具 举报

 楼主| 发表于 6 天前 | 显示全部楼层
sfqxx 发表于 2025-8-14 18:51
哦对了你发的html代码该怎么下载在自己这里运行啊,我不太会

不过直接保存打开网页会有一些功能缺陷

评分

参与人数 1荣誉 +2 收起 理由
sfqxx + 2 OK.

查看全部评分

小甲鱼最新课程 -> https://ilovefishc.com
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Archiver|鱼C工作室 ( 粤ICP备18085999号-1 | 粤公网安备 44051102000585号)

GMT+8, 2025-8-20 15:14

Powered by Discuz! X3.4

© 2001-2023 Discuz! Team.

快速回复 返回顶部 返回列表