Få læst teksten højt ved at markere teksten og tryk på play-knappen.

privatsyn-vergens-simulator.php

<?php
/**
 * Plugin Name: Privatsyn Vergens Simulator (Shortcode)
 * Description: Shortcode [vergens_sim] viser en interaktiv vergens & akkommodations-simulator med alle 6 ekstraokulære muskler pr. øje.
 * Version: 1.0.0
 * Author: Privatsyn / Finn Abildgaard
 */

if (!defined('ABSPATH')) exit;

function privatsyn_vergens_simulator_shortcode($atts = []) {
    // --- Defaults & attributes ---
    $defaults = [
        'distance' => '40',   // cm
        'ipd'      => '6.4',  // cm
        'prismh'   => '0',    // Δ (horisontal, +BO)
        'prismv'   => '0',    // Δ (vertikal, +L op / R ned)
        'cyclo'    => '0',    // ° ( + = L in-cyclo / R ex-cyclo )
        'aca'      => '4',    // Δ/D
        'cac'      => '0.10', // D/Δ
        'tauv'     => '0.25', // s (vergence tidskonstant)
        'taua'     => '0.30', // s (akkommodation tidskonstant)
        'lat'      => '0',    // ms (latens)
        'panum'    => '10',   // buemin
    ];
    $a = shortcode_atts($defaults, $atts, 'vergens_sim');

    // Sanitize (numeriske)
    foreach ($a as $k => $v) {
        $a[$k] = is_numeric($v) ? $v : $defaults[$k];
    }

    // Unik container-id så flere shortcodes kan eksistere på samme side
    $uid = 'vergens_' . uniqid();

    ob_start(); ?>
    <div id="<?php echo esc_attr($uid); ?>" class="vergens-pro-root"
         data-distance="<?php echo esc_attr($a['distance']); ?>"
         data-ipd="<?php echo esc_attr($a['ipd']); ?>"
         data-prismh="<?php echo esc_attr($a['prismh']); ?>"
         data-prismv="<?php echo esc_attr($a['prismv']); ?>"
         data-cyclo="<?php echo esc_attr($a['cyclo']); ?>"
         data-aca="<?php echo esc_attr($a['aca']); ?>"
         data-cac="<?php echo esc_attr($a['cac']); ?>"
         data-tauv="<?php echo esc_attr($a['tauv']); ?>"
         data-taua="<?php echo esc_attr($a['taua']); ?>"
         data-lat="<?php echo esc_attr($a['lat']); ?>"
         data-panum="<?php echo esc_attr($a['panum']); ?>"
         style="max-width:1200px;margin:auto;">
      <style>
        /* ---- Scoped styling to the unique container ---- */
        #<?php echo $uid; ?> * { box-sizing: border-box; font-family: system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif; }
        #<?php echo $uid; ?> .header {
          background: linear-gradient(120deg,#6b818d,#40515a);
          color:#fff; padding:12px 14px; border-radius:10px 10px 0 0;
        }
        #<?php echo $uid; ?> .header h2 { margin:0; font-size:18px; }
        #<?php echo $uid; ?> .container {
          display:grid; grid-template-columns: 360px 1fr; gap:16px;
          background:#fff; padding:14px; border-radius:0 0 10px 10px;
          box-shadow:0 6px 16px rgba(0,0,0,0.08);
        }
        #<?php echo $uid; ?> .controls .group { margin-bottom:12px; }
        #<?php echo $uid; ?> .controls label { display:flex; justify-content:space-between; font-size:13px; color:#40515a; }
        #<?php echo $uid; ?> .controls input[type=range] { width:100%; }
        #<?php echo $uid; ?> .row { display:grid; grid-template-columns: 1fr 1fr; gap:10px; }
        #<?php echo $uid; ?> .small { font-size:12px; color:#6b818d; }
        #<?php echo $uid; ?> .kpis {
          display:grid; grid-template-columns: repeat(4,1fr); gap:8px; margin-top:8px;
        }
        #<?php echo $uid; ?> .kpis .item { background:#f3f6f8; border-radius:8px; padding:6px; text-align:center; }
        #<?php echo $uid; ?> .kpis .item div:first-child { font-size:11px; color:#6b818d; }
        #<?php echo $uid; ?> canvas { width:100%; height:340px; background:#f8fafb; border-radius:10px; }
        #<?php echo $uid; ?> .mini { height:140px !important; margin-top:8px; }
        #<?php echo $uid; ?> .legend { display:flex; gap:12px; flex-wrap:wrap; font-size:12px; color:#40515a; margin-top:6px; }
        #<?php echo $uid; ?> .dot { width:10px; height:10px; display:inline-block; border-radius:2px; }
        #<?php echo $uid; ?> .t { background:#6b818d; }   /* target */
        #<?php echo $uid; ?> .r { background:#2e7d32; }   /* response */
        #<?php echo $uid; ?> .e { background:#c62828; }   /* error */

        /* Responsiv forbedring */
        @media (max-width: 900px) {
          #<?php echo $uid; ?> .container { grid-template-columns: 1fr; }
        }
      </style>

      <div class="header">
        <h2>Vergens & akkommodation — med alle 6 ekstraokulære muskler</h2>
      </div>

      <div class="container">
        <!-- -------- Controls -------- -->
        <div class="controls">
          <div class="group">
            <label>Afstand <span id="lblDist">—</span></label>
            <input id="ctlDist" type="range" min="20" max="600" value="<?php echo esc_attr($a['distance']); ?>" step="1">
            <div class="small">Akkommodationskrav (D) = 1 / (afstand i meter). 40 cm ⇒ 2.5 D</div>
          </div>

          <div class="row">
            <div class="group">
              <label>IPD <span id="lblIPD">—</span></label>
              <input id="ctlIPD" type="range" min="5.0" max="7.5" value="<?php echo esc_attr($a['ipd']); ?>" step="0.1">
            </div>
            <div class="group">
              <label>Horisontal prisme (Δ) <span id="lblPrismH">—</span></label>
              <input id="ctlPrismH" type="range" min="-20" max="20" value="<?php echo esc_attr($a['prismh']); ?>" step="0.5">
              <div class="small">+ = BO (øger konvergenskrav), − = BI (reducerer)</div>
            </div>
          </div>

          <div class="row">
            <div class="group">
              <label>Vertikal disparity (Δv) <span id="lblPrismV">—</span></label>
              <input id="ctlPrismV" type="range" min="-6" max="6" value="<?php echo esc_attr($a['prismv']); ?>" step="0.25">
              <div class="small">+ = venstre øje op / højre øje ned</div>
            </div>
            <div class="group">
              <label>Cyclo-disparitet (°) <span id="lblCyclo">—</span></label>
              <input id="ctlCyclo" type="range" min="-5" max="5" value="<?php echo esc_attr($a['cyclo']); ?>" step="0.25">
              <div class="small">+ = venstre øje in-cyclo / højre øje ex-cyclo (behov)</div>
            </div>
          </div>

          <div class="row">
            <div class="group">
              <label>AC/A (Δ pr D) <span id="lblACA">—</span></label>
              <input id="ctlACA" type="range" min="0" max="8" value="<?php echo esc_attr($a['aca']); ?>" step="0.1">
            </div>
            <div class="group">
              <label>CA/C (D pr Δ) <span id="lblCAC">—</span></label>
              <input id="ctlCAC" type="range" min="0" max="0.25" value="<?php echo esc_attr($a['cac']); ?>" step="0.01">
            </div>
          </div>

          <div class="row">
            <div class="group">
              <label>τ<sub>V</sub> (s) <span id="lblTauV">—</span></label>
              <input id="ctlTauV" type="range" min="0.05" max="1.0" value="<?php echo esc_attr($a['tauv']); ?>" step="0.01">
            </div>
            <div class="group">
              <label>τ<sub>A</sub> (s) <span id="lblTauA">—</span></label>
              <input id="ctlTauA" type="range" min="0.05" max="1.0" value="<?php echo esc_attr($a['taua']); ?>" step="0.01">
            </div>
          </div>

          <div class="row">
            <div class="group">
              <label>Latens (ms) <span id="lblLat">—</span></label>
              <input id="ctlLat" type="range" min="0" max="200" value="<?php echo esc_attr($a['lat']); ?>" step="10">
            </div>
            <div class="group">
              <label>Panum (buemin) <span id="lblPanum">—</span></label>
              <input id="ctlPanum" type="range" min="4" max="30" value="<?php echo esc_attr($a['panum']); ?>" step="1">
            </div>
          </div>

          <div class="small">
            Model (forenklet):<br>
            V<sub>h,cmd</sub> = IPD·D + Prism<sub>H</sub> + (AC/A)·A &nbsp;&nbsp;|&nbsp;&nbsp;
            A<sub>cmd</sub> = D + (CA/C)·V<sub>h</sub><br>
            dV/dt = (cmd − V)/τ, dA/dt = (cmd − A)/τ. Vertikal og cyclo styres af deres respektive kontroller.
          </div>
        </div>

        <!-- -------- Visualization -------- -->
        <div>
          <canvas id="cvScene" width="900" height="340"></canvas>
          <div class="legend">
            <span class="dot t"></span> Mål (V/A) &nbsp;
            <span class="dot r"></span> Respons (V/A) &nbsp;
            <span class="dot e"></span> Fejl
          </div>
          <div class="kpis">
            <div class="item"><div>Akkom. krav</div><div id="kpiD">—</div></div>
            <div class="item"><div>A respons</div><div id="kpiA">—</div></div>
            <div class="item"><div>V<sub>h</sub> krav</div><div id="kpiVt">—</div></div>
            <div class="item"><div>V<sub>h</sub> respons</div><div id="kpiV">—</div></div>
          </div>
          <canvas id="cvGraph" class="mini" width="900" height="140"></canvas>
        </div>
      </div>

      <script>
      (function(){
        // Root-scoped selectors to support multiple instances safely
        const root = document.getElementById('<?php echo $uid; ?>');
        const E = sel => root.querySelector(sel);

        const dist=E('#ctlDist'), ipd=E('#ctlIPD'), prismH=E('#ctlPrismH'), prismV=E('#ctlPrismV'), cyclo=E('#ctlCyclo');
        const aca=E('#ctlACA'), cac=E('#ctlCAC'), tauV=E('#ctlTauV'), tauA=E('#ctlTauA'), lat=E('#ctlLat'), panum=E('#ctlPanum');

        const lblDist=E('#lblDist'), lblIPD=E('#lblIPD'), lblPrismH=E('#lblPrismH'), lblPrismV=E('#lblPrismV'), lblCyclo=E('#lblCyclo');
        const lblACA=E('#lblACA'), lblCAC=E('#lblCAC'), lblTauV=E('#lblTauV'), lblTauA=E('#lblTauA'), lblLat=E('#lblLat'), lblPanum=E('#lblPanum');

        const kpiD=E('#kpiD'), kpiA=E('#kpiA'), kpiVt=E('#kpiVt'), kpiV=E('#kpiV');

        const scene=E('#cvScene'), ctx=scene.getContext('2d');
        const g=E('#cvGraph'), gtx=g.getContext('2d');

        function sync(){
          lblDist.textContent = dist.value+' cm';
          lblIPD.textContent = (+ipd.value).toFixed(1)+' cm';
          lblPrismH.textContent = (+prismH.value).toFixed(1)+' Δ';
          lblPrismV.textContent = (+prismV.value).toFixed(2)+' Δ';
          lblCyclo.textContent  = (+cyclo.value).toFixed(2)+'°';
          lblACA.textContent = (+aca.value).toFixed(1);
          lblCAC.textContent = (+cac.value).toFixed(2);
          lblTauV.textContent = (+tauV.value).toFixed(2);
          lblTauA.textContent = (+tauA.value).toFixed(2);
          lblLat.textContent   = lat.value;
          lblPanum.textContent = panum.value + '′';
        }
        ['input','change'].forEach(evt=>{
          [dist,ipd,prismH,prismV,cyclo,aca,cac,tauV,tauA,lat,panum].forEach(e=>e.addEventListener(evt,sync));
        });
        sync();

        // ---------- State ----------
        let Vh=0, Vv=0, Vc=0;   // vergence states: horizontal Δ, vertical Δ, cyclo °
        let A=0;                // accommodation (D)
        let tPrev=performance.now();

        // latency buffers (store last 1.2 s of cmd values)
        const bufVh=[], bufA=[];
        function pushBuf(buf,v){
          const now=performance.now();
          buf.push({t:now,v});
          while(buf.length && now-buf[0].t>1200){ buf.shift(); }
        }
        function getDelayed(buf,delayMs, fallback){
          if (delayMs<=0 || buf.length===0) return fallback;
          const targetT = performance.now()-delayMs;
          for(let i=buf.length-1;i>=0;i--){
            if (buf[i].t<=targetT) return buf[i].v;
          }
          return buf[0].v;
        }

        // history for graph
        const hist=[];
        const maxHist=600; // ~10 s @60fps

        // ---------- Helpers ----------
        const clamp=(x,a,b)=>Math.max(a,Math.min(b,x));
        function arcminToDelta(m){ return m/34.377; } // 1Δ ≈ 34.377'
        function lerp(a,b,t){ return a+(b-a)*t; }
        function actColor(a){ // grey -> green
          a=clamp(a,0,1);
          const r=Math.round(lerp(180,46,a));
          const g_=Math.round(lerp(180,125,a));
          const b=Math.round(lerp(180,50,a));
          return `rgb(${r},${g_},${b})`;
        }
        function vergenceDistanceMeters(delta, ipd_cm){
          const Deq = delta/Math.max(ipd_cm,1e-6);
          return (Deq<=0)? Infinity : 1/Deq;
        }

        // ---------- Core model ----------
        function readUI(){
          const distance_cm = +dist.value;
          const D = 1/Math.max(distance_cm/100, 0.001);   // accommodative demand (D)
          const ipd_cm = +ipd.value;
          const ph = +prismH.value; // horizontal prism Δ
          const pv = +prismV.value; // vertical Δ
          const cyc = +cyclo.value; // degrees
          const ACA = +aca.value;   // Δ per D
          const CAC = +cac.value;   // D per Δ
          const TauV = +tauV.value;
          const TauA = +tauA.value;
          const Lat = +lat.value;
          const PanumDelta = arcminToDelta(+panum.value);
          return {D, ipd_cm, ph, pv, cyc, ACA, CAC, TauV, TauA, Lat, PanumDelta};
        }

        function step(dt){
          const st = readUI();

          // Commands (desired)
          // Horizontal vergence command includes geometric + prism + AC/A*A
          const Vh_cmd = st.ipd_cm*st.D + st.ph + st.ACA * A;
          // Vertical and cyclo are driven directly by the sliders (smoothed)
          const Vv_cmd = st.pv;         // Δ vertical
          const Vc_cmd = st.cyc;        // ° cyclo (torsion)

          // Accommodation command includes demand + CA/C * Vh
          const A_cmd  = st.D + st.CAC * Vh;

          // Latency (apply to Vh and A)
          pushBuf(bufVh, Vh_cmd);
          pushBuf(bufA,  A_cmd);
          const Vh_cmd_d = getDelayed(bufVh, st.Lat, Vh_cmd);
          const A_cmd_d  = getDelayed(bufA,  st.Lat, A_cmd);

          // First-order dynamics
          Vh += (Vh_cmd_d - Vh) * dt / Math.max(0.001, st.TauV);
          Vv += (Vv_cmd - Vv) * dt / Math.max(0.001, st.TauV);
          Vc += (Vc_cmd - Vc) * dt / Math.max(0.001, st.TauV);
          A  += (A_cmd_d  - A ) * dt / Math.max(0.001, st.TauA);

          // Clamp reasonable ranges
          Vh = clamp(Vh, -40, 40);
          Vv = clamp(Vv, -10, 10);
          Vc = clamp(Vc, -10, 10);
          A  = clamp(A, 0, 12);

          // Errors for diplopi (horizontal)
          const errH = (st.ipd_cm*st.D + st.ph + st.ACA * A) - Vh;

          hist.push({Vh_cmd, Vh, A_cmd, A});
          if (hist.length>maxHist) hist.shift();

          // KPIs
          kpiD.textContent = st.D.toFixed(2)+' D';
          kpiA.textContent = A.toFixed(2)+' D';
          kpiVt.textContent= Vh_cmd.toFixed(2)+' Δ';
          kpiV.textContent = Vh.toFixed(2)+' Δ';

          drawScene(st, {Vh_cmd, Vv_cmd, Vc_cmd, A_cmd}, {Vh, Vv, Vc, A}, errH);
          drawGraph();
        }

        // ---------- Drawing ----------
        function drawScene(st, cmd, resp, errH){
          const w=scene.width, h=scene.height;
          const dpr = window.devicePixelRatio || 1;
          // handle crisp canvas on HiDPI
          if (scene._dpr !== dpr) {
            scene._dpr = dpr;
            scene.width = Math.floor(scene.clientWidth * dpr);
            scene.height = Math.floor(scene.clientHeight * dpr);
          }
          ctx.setTransform(dpr,0,0,dpr,0,0);
          ctx.clearRect(0,0,scene.clientWidth,scene.clientHeight);

          const baseY = scene.clientHeight - 70;
          const scaleZ = 320; // m -> px for target placement
          const ipd_px = (st.ipd_cm/100)*scaleZ*4.5;
          const eyeL = {x:scene.clientWidth/2 - ipd_px/2, y:baseY};
          const eyeR = {x:scene.clientWidth/2 + ipd_px/2, y:baseY};

          // Target position (depth only visual cue)
          const target = { x:scene.clientWidth/2, y: baseY - (1/st.D)*scaleZ };

          // Axes
          ctx.strokeStyle='#e6eef1'; ctx.lineWidth=1.5;
          ctx.setLineDash([6,6]);
          ctx.beginPath(); ctx.moveTo(20,baseY); ctx.lineTo(scene.clientWidth-20,baseY); ctx.stroke();
          ctx.beginPath(); ctx.moveTo(scene.clientWidth/2,baseY); ctx.lineTo(scene.clientWidth/2,20); ctx.stroke();
          ctx.setLineDash([]);

          // Target (single/double horizontally if outside Panum)
          const panumPxPerDelta = (1/st.D)*0.00995*scaleZ; // px per Δ at target plane
          const panumPx = st.PanumDelta * panumPxPerDelta;
          const errPx = errH * panumPxPerDelta;
          const showDouble = Math.abs(errH) > st.PanumDelta;

          function drawDiamond(x,y,color){
            ctx.fillStyle=color; ctx.beginPath();
            ctx.moveTo(x, y-8); ctx.lineTo(x+8,y); ctx.lineTo(x,y+8); ctx.lineTo(x-8,y); ctx.closePath(); ctx.fill();
          }
          if (showDouble){
            drawDiamond(target.x - errPx/2, target.y, '#c62828');
            drawDiamond(target.x + errPx/2, target.y, '#c62828');
          } else {
            drawDiamond(target.x, target.y, '#6b818d');
          }

          // Eyes
          function drawEye(pt){
            ctx.fillStyle='#30424a';
            ctx.beginPath(); ctx.arc(pt.x, pt.y, 16, 0, Math.PI*2); ctx.fill();
            ctx.fillStyle='#6b818d';
            ctx.beginPath(); ctx.arc(pt.x, pt.y, 7, 0, Math.PI*2); ctx.fill();
          }
          drawEye(eyeL); drawEye(eyeR);

          // Response point (approx.) from vergence
          const zV = vergenceDistanceMeters(resp.Vh, st.ipd_cm);
          const vPt = { x:scene.clientWidth/2, y: baseY - Math.min(5.0, zV)*scaleZ };

          // Demand rays
          ctx.strokeStyle='#6b818d'; ctx.lineWidth=2;
          ctx.beginPath(); ctx.moveTo(eyeL.x,eyeL.y); ctx.lineTo(target.x, target.y); ctx.stroke();
          ctx.beginPath(); ctx.moveTo(eyeR.x,eyeR.y); ctx.lineTo(target.x, target.y); ctx.stroke();

          // Response rays
          ctx.strokeStyle='#2e7d32';
          ctx.beginPath(); ctx.moveTo(eyeL.x,eyeL.y); ctx.lineTo(vPt.x, vPt.y); ctx.stroke();
          ctx.beginPath(); ctx.moveTo(eyeR.x,eyeR.y); ctx.lineTo(vPt.x, vPt.y); ctx.stroke();

          // Panum band at target
          ctx.strokeStyle='#ffb74d'; ctx.setLineDash([4,4]);
          ctx.beginPath(); ctx.moveTo(scene.clientWidth/2 - panumPx, target.y); ctx.lineTo(scene.clientWidth/2 + panumPx, target.y); ctx.stroke();
          ctx.setLineDash([]);

          // ---------- Muscles (six per eye) ----------
          const clamp01=(v)=>Math.max(0,Math.min(1,v));
          const satH=15, satV=6, satC=5; // saturation levels for visualization
          const Hpos = Math.max(-1,Math.min(1, cmd.Vh_cmd/satH)); // + konvergens
          const Vpos = Math.max(-1,Math.min(1, cmd.Vv_cmd/satV)); // + L op / R ned
          const Cpos = Math.max(-1,Math.min(1, cmd.Vc_cmd/satC)); // + L in-cyclo / R ex-cyclo

          const acts = {
            L: {
              MR: clamp01( Hpos>0?  Math.abs(Hpos): 0),
              LR: clamp01( Hpos<0?  Math.abs(Hpos): 0),
              SR: clamp01( Vpos>0?  Math.abs(Vpos): 0),
              IR: clamp01( Vpos<0?  Math.abs(Vpos): 0),
              SO: clamp01( Cpos>0?  Math.abs(Cpos): 0), // incyclo
              IO: clamp01( Cpos<0?  Math.abs(Cpos): 0), // excyclo
            },
            R: {
              MR: clamp01( Hpos>0?  Math.abs(Hpos): 0),
              LR: clamp01( Hpos<0?  Math.abs(Hpos): 0),
              SR: clamp01( Vpos<0?  Math.abs(Vpos): 0), // opposite vertical
              IR: clamp01( Vpos>0?  Math.abs(Vpos): 0),
              SO: clamp01( Cpos<0?  Math.abs(Cpos): 0), // opposite torsion
              IO: clamp01( Cpos>0?  Math.abs(Cpos): 0),
            }
          };

          function band(x1,y1,x2,y2,a){ ctx.lineWidth=8; ctx.strokeStyle=actColor(a); ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke(); }
          function label(ptx,pty,text){ ctx.fillStyle='#40515a'; ctx.font='11px system-ui'; ctx.fillText(text, ptx, pty); }

          const off=42, diag=30;
          // Left eye
          band(eyeL.x+16, eyeL.y, eyeL.x+off, eyeL.y, acts.L.MR); // MR (nasal)
          band(eyeL.x-16, eyeL.y, eyeL.x-off, eyeL.y, acts.L.LR); // LR (temporal)
          band(eyeL.x, eyeL.y-16, eyeL.x, eyeL.y-16-off+16, acts.L.SR); // SR
          band(eyeL.x, eyeL.y+16, eyeL.x, eyeL.y+off,         acts.L.IR); // IR
          band(eyeL.x+12, eyeL.y-12, eyeL.x+12+diag, eyeL.y-12-diag, acts.L.SO); // SO
          band(eyeL.x+12, eyeL.y+12, eyeL.x+12+diag, eyeL.y+12+diag, acts.L.IO); // IO

          // Right eye
          band(eyeR.x-16, eyeR.y, eyeR.x-off, eyeR.y, acts.R.MR); // MR (nasal)
          band(eyeR.x+16, eyeR.y, eyeR.x+off, eyeR.y, acts.R.LR); // LR (temporal)
          band(eyeR.x, eyeR.y-16, eyeR.x, eyeR.y-16-off+16, acts.R.SR);
          band(eyeR.x, eyeR.y+16, eyeR.x, eyeR.y+off,         acts.R.IR);
          band(eyeR.x-12, eyeR.y-12, eyeR.x-12-diag, eyeR.y-12-diag, acts.R.SO);
          band(eyeR.x-12, eyeR.y+12, eyeR.x-12-diag, eyeR.y+12+diag, acts.R.IO);

          // Labels (små)
          label(eyeL.x-54, eyeL.y-44, 'LR'); label(eyeL.x+40, eyeL.y-44, 'SR  SO');
          label(eyeL.x-54, eyeL.y+56, 'IR'); label(eyeL.x+40, eyeL.y+56, 'IO  MR');
          label(eyeR.x-56, eyeR.y-44, 'SR  SO'); label(eyeR.x+42, eyeR.y-44, 'LR');
          label(eyeR.x-56, eyeR.y+56, 'IO  MR'); label(eyeR.x+42, eyeR.y+56, 'IR');

          // Status tekst
          ctx.fillStyle='#40515a'; ctx.font='12px system-ui';
          ctx.fillText(`D: ${readUI().D.toFixed(2)} D  |  Vh: ${resp.Vh.toFixed(2)} Δ (cmd ${cmd.Vh_cmd.toFixed(2)} Δ)  |  A: ${resp.A.toFixed(2)} D (cmd ${cmd.A_cmd.toFixed(2)} D)`, 16, 20);
          ctx.fillStyle = (Math.abs(errH)>st.PanumDelta)? '#c62828' : '#2e7d32';
          ctx.fillText(`Status: ${Math.abs(errH)>st.PanumDelta ? 'Diplopi (horisontal)' : 'Fusion'}`, 16, 36);
        }

        function drawGraph(){
          const w=g.clientWidth, h=g.clientHeight;
          const dpr = window.devicePixelRatio || 1;
          if (g._dpr !== dpr) {
            g._dpr = dpr;
            g.width = Math.floor(w * dpr);
            g.height = Math.floor(h * dpr);
          }
          gtx.setTransform(dpr,0,0,dpr,0,0);
          gtx.clearRect(0,0,w,h);
          // Axes
          gtx.strokeStyle='#e6eef1'; gtx.lineWidth=1;
          gtx.beginPath(); gtx.moveTo(36,10); gtx.lineTo(36,h-20); gtx.lineTo(w-10,h-20); gtx.stroke();

          if (hist.length<2) return;
          let minY=1e9, maxY=-1e9;
          hist.forEach(p=>{
            minY=Math.min(minY, p.Vh_cmd,p.Vh,p.A_cmd,p.A);
            maxY=Math.max(maxY, p.Vh_cmd,p.Vh,p.A_cmd,p.A);
          });
          if (!isFinite(minY)||!isFinite(maxY)){ minY=-10; maxY=10; }
          if (maxY-minY < 1e-3){ maxY+=1; minY-=1; }

          const padTop=10, padBot=20, padLeft=36, padRight=10;
          const yMap=v=> padTop + (maxY-v)*(h-padTop-padBot)/(maxY-minY);
          const xMap=i=> padLeft + i*(w-padLeft-padRight)/(Math.max(1,hist.length-1));

          function drawSeries(color,sel){
            gtx.strokeStyle=color; gtx.lineWidth=2; gtx.beginPath();
            hist.forEach((p,i)=>{ const x=xMap(i), y=yMap(sel(p)); if(i===0) gtx.moveTo(x,y); else gtx.lineTo(x,y); });
            gtx.stroke();
          }
          drawSeries('#6b818d', p=>p.Vh_cmd);
          drawSeries('#2e7d32', p=>p.Vh);
          drawSeries('#9fb2bb', p=>p.A_cmd);
          drawSeries('#66a36a', p=>p.A);

          // y labels
          gtx.fillStyle='#40515a'; gtx.font='11px system-ui';
          gtx.fillText(maxY.toFixed(1), 4, yMap(maxY)+4);
          gtx.fillText(((maxY+minY)/2).toFixed(1), 2, yMap((maxY+minY)/2)+4);
          gtx.fillText(minY.toFixed(1), 4, yMap(minY)+4);
        }

        // ---------- Main loop ----------
        // Initialize from dataset defaults (already set via inputs), but start at equilibrium
        (function init(){
          const st=readUI();
          const Vh0 = st.ipd_cm*st.D + st.ph + st.ACA*st.D;
          Vh=Vh0; A=st.D;
        })();

        function tick(){
          const tNow = performance.now();
          const dt = Math.min(0.05, (tNow - tPrev)/1000);
          tPrev = tNow;
          step(dt);
          requestAnimationFrame(tick);
        }
        tick();

        // Resize handling for canvases
        let ro;
        if ('ResizeObserver' in window) {
          ro = new ResizeObserver(()=>{ /* redraw next frame will adapt */ });
          ro.observe(root);
        }

        // --- Local readUI captured for labels in status line ---
        function readUI(){
          const distance_cm = +dist.value;
          const D = 1/Math.max(distance_cm/100, 0.001);
          const ipd_cm = +ipd.value;
          const ph = +prismH.value;
          const pv = +prismV.value;
          const cyc = +cyclo.value;
          const ACA = +aca.value;
          const CAC = +cac.value;
          const TauV = +tauV.value;
          const TauA = +tauA.value;
          const Lat = +lat.value;
          const PanumDelta = (+panum.value)/34.377;
          return {D, ipd_cm, ph, pv, cyc, ACA, CAC, TauV, TauA, Lat, PanumDelta};
        }
      })();
      </script>
    </div>
    <?php
    return ob_get_clean();
}
add_shortcode('vergens_sim', 'privatsyn_vergens_simulator_shortcode');