<?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 |
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)
<span class="dot r"></span> Respons (V/A)
<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');