Files
ping-pong-ping/index.html
Poulpe ab7140968a fix(anim 1): cycle guard interval + ping2 visual ring
Two fixes for the visual confusion of 'A appears to send two pings back to back':

1. Add 600 ms (sim) guard interval between cycle N's tPing2Recv and cycle N+1's
   tPing1Send. Removes the visual hiccup where ping2 (end of cycle N) and ping1
   (start of cycle N+1) collided as two consecutive A→B cyan pulses.

2. Distinguish ping2 from ping1 visually: ping1 = solid filled circle, ping2 =
   hollow ring (same cyan color). Legend updated. The user can now tell at a
   glance which transmission is which without relying on the small label text.

Reported by Flag 2026-04-27, fixed same day.
2026-04-27 14:40:00 +00:00

1129 lines
45 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ping-Pong-Ping — Mesure symétrique de distance USBL</title>
<style>
:root {
--bg: #0b1220;
--panel: #131c2e;
--line: #1e2a44;
--fg: #e8eef9;
--mute: #92a0bd;
--accent: #5ad1ff;
--accent2: #ff8b5a;
--ok: #6ee7a7;
--warn: #fbbf24;
}
* { box-sizing: border-box; }
html, body {
background: var(--bg);
color: var(--fg);
font-family: -apple-system, "SF Pro Text", "Segoe UI", Roboto, Ubuntu, sans-serif;
margin: 0;
line-height: 1.55;
}
main {
max-width: 940px;
margin: 0 auto;
padding: 32px 22px 80px;
}
h1 { font-size: 1.9rem; margin: 0 0 6px; }
h2 { font-size: 1.25rem; margin: 36px 0 10px; color: var(--accent); border-bottom: 1px solid var(--line); padding-bottom: 6px; }
h3 { font-size: 1.05rem; margin: 22px 0 6px; color: var(--accent2); }
p, li { color: var(--fg); }
.sub { color: var(--mute); margin-top: 0; }
code, .mono { font-family: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; font-size: 0.92em; }
code { background: #1b2540; padding: 1px 6px; border-radius: 4px; }
pre {
background: #0e1628;
border: 1px solid var(--line);
padding: 14px 16px;
border-radius: 8px;
overflow-x: auto;
font-size: 0.88rem;
color: #d4dcef;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 10px;
padding: 16px 18px;
margin: 18px 0;
}
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
button {
background: var(--accent);
border: 0;
color: #062235;
padding: 8px 14px;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 0.95rem;
}
button.ghost {
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
}
button:hover { filter: brightness(1.1); }
label { color: var(--mute); font-size: 0.9rem; display: inline-flex; gap: 8px; align-items: center; }
input[type=range] { accent-color: var(--accent); width: 160px; }
canvas { width: 100%; display: block; background: #06101e; border-radius: 8px; border: 1px solid var(--line); }
table { width: 100%; border-collapse: collapse; margin: 10px 0; }
th, td { padding: 6px 10px; border-bottom: 1px solid var(--line); text-align: left; font-size: 0.92rem; }
th { color: var(--accent); font-weight: 600; }
.kv { display: grid; grid-template-columns: 1fr auto; gap: 6px 14px; font-family: ui-monospace, monospace; font-size: 0.9rem; }
.kv .k { color: var(--mute); }
.kv .v { color: var(--ok); text-align: right; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
ul.tight li { margin-bottom: 4px; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 0.78rem; font-weight: 600; }
.tag.A { background: #0d3a5e; color: var(--accent); }
.tag.B { background: #5a2d12; color: var(--accent2); }
footer { margin-top: 60px; color: var(--mute); font-size: 0.82rem; text-align: center; }
.legend { display: flex; gap: 14px; font-size: 0.85rem; color: var(--mute); }
.legend .sw { display: inline-block; width: 12px; height: 12px; border-radius: 2px; vertical-align: middle; margin-right: 4px; }
@media (max-width: 700px) {
.grid2 { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main>
<h1>Ping-Pong-Ping</h1>
<p class="sub">Mesure symétrique de distance par échange acoustique <span class="mono">3-way</span> — DS-TWR (Double-Sided Two-Way Ranging)</p>
<h2>Le problème</h2>
<p>
En USBL classique, deux modes coexistent pour mesurer la distance entre deux transducteurs immergés&nbsp;:
</p>
<ul class="tight">
<li><b>Horloges pré-synchronisées</b> — chaque appareil horodate l'émission et la réception. Précis mais nécessite une synchro fine (drift d'horloge à compenser).</li>
<li><b>Mode transpondeur</b> — le maître envoie un ping, l'esclave répond. Le maître mesure le temps aller-retour. <b>Seul le maître connaît la distance.</b></li>
</ul>
<p>
Si les <b>deux</b> appareils doivent connaître la distance simultanément (ex&nbsp;: deux véhicules autonomes coopératifs, swarm BlueROV, balise mobile/mobile), le mode transpondeur classique est asymétrique.
</p>
<h2>Le principe — 3 transmissions, 2 mesures identiques</h2>
<p>
On échange <b>trois</b> messages au lieu de deux. Chaque appareil mesure son propre <i>turnaround</i> local (le temps entre réception d'un message et émission du suivant), puis l'<b>encode dans la trame</b> renvoyée. Les deux côtés peuvent alors retirer ce délai du round-trip qu'ils observent → ils calculent le même TOF (Time of Flight) sans avoir jamais synchronisé leurs horloges.
</p>
<div class="panel">
<h3>Séquence</h3>
<table>
<tr><th>#</th><th>Émetteur</th><th>Contenu</th><th>Mesure côté récepteur</th></tr>
<tr><td>1</td><td><span class="tag A">A</span><span class="tag B">B</span></td><td>Ping (id session)</td><td>B note T<sub>1</sub><sup>B</sup></td></tr>
<tr><td>2</td><td><span class="tag B">B</span><span class="tag A">A</span></td><td>Pong + <code>turnaround_B</code></td><td>A note T<sub>2</sub><sup>A</sup>, calcule TOF</td></tr>
<tr><td>3</td><td><span class="tag A">A</span><span class="tag B">B</span></td><td>Ping2 + <code>turnaround_A</code></td><td>B note T<sub>3</sub><sup>B</sup>, calcule TOF</td></tr>
</table>
</div>
<h3>Formule</h3>
<pre>
turnaround_B = T_send_pong_B T_recv_ping1_B (mesuré par B, encodé dans pong)
turnaround_A = T_send_ping2_A T_recv_pong_A (mesuré par A, encodé dans ping2)
round_trip_A = T_recv_pong_A T_send_ping1_A (mesuré par A localement)
round_trip_B = T_recv_ping2_B T_send_pong_B (mesuré par B localement)
TOF_A = (round_trip_A turnaround_B) / 2
TOF_B = (round_trip_B turnaround_A) / 2
distance = TOF · c avec c ≈ 1500 m/s en eau de mer
</pre>
<h2>Animation continue — B mobile</h2>
<p class="sub">B oscille en distance par rapport à A (fixe). Les cycles ping-pong-ping s'enchaînent en boucle ; chaque cycle complet ajoute un point de mesure au plot. La distance vraie (rouge pointillé) est comparée aux distances mesurées côté A (cyan) et côté B (orange).</p>
<div class="panel">
<div class="row" style="margin-bottom:10px;">
<button id="btnPlay">▶ Démarrer</button>
<button class="ghost" id="btnReset">↺ Reset</button>
<label>Vitesse sim <input type="range" id="speed" min="0.2" max="6" step="0.1" value="2"></label>
<label>Amplitude B (m) <input type="range" id="amp" min="20" max="500" step="10" value="100"></label>
<label>Période B (s sim) <input type="range" id="period" min="60" max="600" step="10" value="300"></label>
</div>
<canvas id="cv" width="900" height="280"></canvas>
<div style="margin-top:10px;" class="legend">
<span><span class="sw" style="background:#5ad1ff"></span>USBL A (fixe)</span>
<span><span class="sw" style="background:#ff8b5a"></span>USBL B (mobile)</span>
<span><span class="sw" style="background:#5ad1ff"></span>ping1 (A→B, plein)</span>
<span><span class="sw" style="background:#ff8b5a"></span>pong (B→A, plein)</span>
<span><span class="sw" style="background:transparent;border:2px solid #5ad1ff;border-radius:2px"></span>ping2 (A→B, anneau)</span>
</div>
<canvas id="plot" width="900" height="220" style="margin-top:14px;"></canvas>
<div class="legend" style="margin-top:6px;">
<span><span class="sw" style="background:#fbbf24"></span>Distance vraie</span>
<span><span class="sw" style="background:#5ad1ff"></span>Mesure côté A (TOF_A · c)</span>
<span><span class="sw" style="background:#ff8b5a"></span>Mesure côté B (TOF_B · c)</span>
</div>
<div class="grid2" style="margin-top:14px;">
<div class="kv">
<div class="k">temps sim</div><div class="v" id="simT">0.00 s</div>
<div class="k">distance vraie</div><div class="v" id="dTrue">— m</div>
<div class="k">cycles complets</div><div class="v" id="nCycles">0</div>
<div class="k">turnaround A / B</div><div class="v">80 / 50 ms</div>
</div>
<div class="kv">
<div class="k">dernière mesure A</div><div class="v" id="dLastA">— m</div>
<div class="k">dernière mesure B</div><div class="v" id="dLastB">— m</div>
<div class="k">erreur A (vs vraie au départ)</div><div class="v" id="errA">— m</div>
<div class="k">erreur B (vs vraie au départ)</div><div class="v" id="errB">— m</div>
</div>
</div>
</div>
<h2>Ce qu'il faut observer</h2>
<ul class="tight">
<li><b>Concordance A et B</b> — les deux mesures suivent la courbe vraie. C'est le bénéfice principal du 3-way&nbsp;: deux observateurs, une seule vérité.</li>
<li><b>Petit retard de phase</b> — chaque mesure est ancrée au début de cycle (instant du <code>ping1</code>). Quand B se déplace vite, la mesure publiée correspond à la position de B il y a <code>~3·TOF + turnarounds</code> secondes (jusqu'à 6&nbsp;s à 3&nbsp;km). Visible quand la période d'oscillation est courte.</li>
<li><b>Biais lié au mouvement de B&nbsp;: négligeable en pratique</b> — pour un AUV à 1&nbsp;kt (≈ 0,5&nbsp;m/s) et c = 1500&nbsp;m/s, B se déplace ~1&nbsp;m pendant un TOF de 2&nbsp;s à 3&nbsp;km. Très en-dessous du bruit USBL natif (typiquement 0,52&nbsp;% de la slant range, soit 1560&nbsp;m à 3&nbsp;km). Même à 5&nbsp;kt c'est ~5&nbsp;m, encore noyé dans le bruit. Donc en ops AUV typiques (13&nbsp;kt), <b>oublier ce biais</b>&nbsp;; il ne devient significatif qu'au-delà de ~10&nbsp;kt sur des cibles très éloignées.</li>
</ul>
<h2>Multi-AUV — adressage séquentiel + écoute passive</h2>
<p class="sub">Cas d'école standard&nbsp;: un <b>USV de surface</b> (master, équipé GPS) interroge ses <b>AUVs en plongée</b> (slaves) un par un. L'USV envoie un ping ciblé sur l'ID d'un AUV&nbsp;; seul l'AUV adressé répond avec son pong. <b>Pas de ping2</b>&nbsp;: seul l'USV a besoin de la distance (il pilote la trilatération depuis la surface), donc SS-TWR suffit.</p>
<p class="sub"><b>Bonus : écoute passive (OWR).</b> Le médium est partagé — quand l'USV émet le ping, <b>tous les AUVs l'entendent</b>, pas que la cible. Les AUVs non adressés ne répondent pas (silence radio), mais ils peuvent <b>extraire leur propre TOF</b> à condition de partager une référence temporelle avec l'USV (horloges disciplinées GPS&nbsp;/&nbsp;AOA pré-mission, ou ping qui embarque <code>T_send</code> dans sa trame). Chacun calcule&nbsp;: <code>distance = (T_recv_local T_send_USV) · c</code>. C'est de la <b>one-way ranging</b> (OWR), comme GPS.</p>
<p class="sub"><b>Conséquence&nbsp;:</b> 1 ping → <b>N mesures</b> de distance USV↔AUV (1 active TWR pour la cible, N1 passives OWR pour les autres). Le médium reste libre, et l'USV peut maintenir un fix sur tout le swarm en parallèle. Visible dans l'anim&nbsp;: tous les AUVs affichent <span style="color:#5ad1ff">📡&nbsp;passive OWR</span> ou <span style="color:#6ee7a7">&nbsp;active TWR</span> à chaque cycle, et tous les points apparaissent dans le plot.</p>
<div class="panel">
<div class="row" style="margin-bottom:10px;">
<button id="btnPlay2">▶ Démarrer</button>
<button class="ghost" id="btnReset2">↺ Reset</button>
<label>Vitesse sim <input type="range" id="speed2" min="0.2" max="6" step="0.1" value="2"></label>
<label>Nb AUVs <input type="range" id="nB" min="2" max="6" step="1" value="4"></label>
<span id="nBLabel" class="mono" style="color:var(--accent)">4</span>
</div>
<canvas id="cv2" width="900" height="340"></canvas>
<div class="legend" id="legend2" style="margin-top:8px;"></div>
<canvas id="plot2" width="900" height="240" style="margin-top:14px;"></canvas>
<div class="kv" style="margin-top:14px;">
<div class="k">temps sim</div><div class="v" id="simT2">0.00 s</div>
<div class="k">phase courante</div><div class="v" id="curSlot"></div>
<div class="k">cycles complets</div><div class="v" id="nCycles2">0</div>
<div class="k">durée d'un cycle (1 AUV)</div><div class="v" id="fullCycleDur">— s</div>
<div class="k">durée scan complet (N AUVs)</div><div class="v" id="cmpDur">— s</div>
</div>
<div id="lastReadings" class="kv" style="margin-top:8px; grid-template-columns: repeat(2, 1fr auto); gap: 4px 14px;"></div>
</div>
<h2>Exemple chiffré (cas statique)</h2>
<p>Distance vraie&nbsp;: <b>3000&nbsp;m</b>. Célérité&nbsp;: <b>1500&nbsp;m/s</b> → TOF unidirectionnel = <b>2.000&nbsp;s</b>.</p>
<pre>
A envoie ping1 à T_A = 0.000 s
B reçoit ping1 à T_B = 2.000 s (TOF = 2 s, mais B ignore T_A)
B envoie pong à T_B = 2.050 s → turnaround_B = 50 ms encodé dans pong
A reçoit pong à T_A = 4.050 s
→ round_trip_A = 4.050 s
→ TOF_A = (4.050 0.050) / 2 = 2.000 s ✓
→ distance = 2.000 × 1500 = 3000 m ✓
A envoie ping2 à T_A = 4.130 s → turnaround_A = 80 ms encodé dans ping2
B reçoit ping2 à T_B = 6.130 s
→ round_trip_B = 6.130 2.050 = 4.080 s
→ TOF_B = (4.080 0.080) / 2 = 2.000 s ✓
→ distance = 3000 m ✓
</pre>
<h2>Cas d'usage</h2>
<ul class="tight">
<li><b>Swarm sous-marin</b> — flottille d'AUV / BlueROV qui doivent maintenir une formation. Chacun connaît sa distance aux voisins en parallèle.</li>
<li><b>Mobile-mobile</b> — deux plongeurs ou deux balises mobiles, sans station fixe maître.</li>
<li><b>Redondance / cross-check</b> — deux mesures TOF indépendantes (côté A et côté B) doivent être identiques. Un écart trahit un bruit ou un multipath.</li>
<li><b>Synchro douce</b> — l'échange permet aussi d'estimer le drift relatif des horloges et d'éviter une vraie synchro hardware.</li>
</ul>
<h2>Limites</h2>
<ul class="tight">
<li><b>Latence du DS-TWR</b> — 3 transmissions au lieu de 2 = ~50&nbsp;% de cycle en plus. À 3&nbsp;km, ~6&nbsp;s par mesure (DS-TWR symétrique) vs ~4&nbsp;s pour SS-TWR. Inadapté à un tracking rapide haut débit&nbsp;; pour du multi-cible, raccourcir la portée ou utiliser SS-TWR si seul un côté a besoin de la distance.</li>
<li><b>Bande passante</b> — il faut encoder <code>turnaround</code> dans la trame acoustique (typiquement 16 ou 32 bits). Coût négligeable mais non nul.</li>
<li><b>Drift d'horloge non-linéaire</b> — sur des séquences longues (>10&nbsp;s) ou des oscillateurs très bas de gamme, l'hypothèse de linéarité casse. DS-TWR le mitige beaucoup mieux que SS-TWR mais ne l'élimine pas.</li>
<li><b>Pas de TDOA</b> — ce mode donne la distance, pas l'angle. Il complète un USBL (qui mesure l'azimut) mais ne le remplace pas.</li>
</ul>
<h2>Référence terrestre — DecaWave UWB</h2>
<p>
Ce schéma est exactement celui qu'utilisent les puces UWB <b>DecaWave DW1000 / DW3000</b> (Apple AirTag, Qorvo, etc.) sous le nom <b>DS-TWR</b>. La transposition acoustique change uniquement l'échelle&nbsp;: célérité 1500&nbsp;m/s vs 3·10⁸&nbsp;m/s, et turnaround mesuré en ms plutôt qu'en ns. La logique de calcul est identique.
</p>
<footer>Poulpe 🐙 — pour Flag — 2026-04-27</footer>
</main>
<script>
(() => {
const C = 1500; // sound speed m/s
const TURNAROUND_B = 0.050; // s
const TURNAROUND_A = 0.080; // s
const D_BASE = 3000; // mean distance m
const D_MIN_CLAMP = 300; // floor
// Realistic AUV motion: amp 100 m + period 300 s → max radial speed ≈ 2 m/s ≈ 4 kt
let amplitude = 100;
let period = 300;
let speedScale = 2;
let playing = false;
let lastTs = 0;
let simT = 0;
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
const plot = document.getElementById('plot');
const pctx = plot.getContext('2d');
// True distance as a function of sim time
function trueDist(t) {
const d = D_BASE + amplitude * Math.sin(2 * Math.PI * t / period);
return Math.max(D_MIN_CLAMP, d);
}
// === Cycle scheduling ===
// We pre-compute a cycle starting at startT.
// Each leg propagates at finite c with B moving — we solve iteratively
// for the reception time given the sender's position at send time.
// A is fixed at x = 0. B is at x = trueDist(t).
function legArrival(senderIsA, sendT) {
// sender position fixed at sendT; receiver moves; pulse travels
// at speed C from sender_pos toward receiver.
// For A→B (senderIsA=true): pulse leaves x=0 at sendT, travels +x at speed C.
// Meets receiver B at time t when C*(t-sendT) = trueDist(t) (B is at that x).
// For B→A (senderIsA=false): pulse leaves x=trueDist(sendT) at sendT, travels -x.
// Meets receiver A at time t when trueDist(sendT) - C*(t-sendT) = 0
// → t = sendT + trueDist(sendT)/C (A doesn't move)
if (!senderIsA) {
return sendT + trueDist(sendT) / C;
}
// iterative for A→B
let t = sendT + trueDist(sendT) / C;
for (let i = 0; i < 6; i++) {
t = sendT + trueDist(t) / C;
}
return t;
}
function buildCycle(startT) {
const tPing1Send = startT;
const tPing1Recv = legArrival(true, tPing1Send);
const tPongSend = tPing1Recv + TURNAROUND_B;
const tPongRecv = legArrival(false, tPongSend);
const tPing2Send = tPongRecv + TURNAROUND_A;
const tPing2Recv = legArrival(true, tPing2Send);
const round_trip_A = tPongRecv - tPing1Send;
const TOF_A = (round_trip_A - TURNAROUND_B) / 2;
const dist_A = TOF_A * C;
const round_trip_B = tPing2Recv - tPongSend;
const TOF_B = (round_trip_B - TURNAROUND_A) / 2;
const dist_B = TOF_B * C;
return {
tPing1Send, tPing1Recv, tPongSend, tPongRecv, tPing2Send, tPing2Recv,
d_at_start: trueDist(tPing1Send),
d_at_end: trueDist(tPing2Recv),
dist_A, dist_B,
// Recorded "publishing" timestamps (when each side learns):
tA_publish: tPongRecv,
tB_publish: tPing2Recv,
};
}
let cycle = null;
// history: array of { tStart, dTrueAtStart, dA, dB, tAppublished, tBpublished }
const history = [];
let nCycles = 0;
// Guard interval (s sim) between two consecutive cycles, to avoid the visual
// confusion where A's ping2 (end of cycle N) and ping1 (start of cycle N+1)
// look like two back-to-back transmissions from A.
const CYCLE_GUARD = 0.6;
function ensureCycle() {
if (!cycle || simT >= cycle.tPing2Recv + CYCLE_GUARD) {
if (cycle) {
history.push({
tStart: cycle.tPing1Send,
dTrueAtStart: cycle.d_at_start,
dA: cycle.dist_A,
dB: cycle.dist_B,
tA: cycle.tA_publish,
tB: cycle.tB_publish,
});
nCycles++;
if (history.length > 600) history.shift();
}
const startT = cycle ? cycle.tPing2Recv + CYCLE_GUARD : 0;
cycle = buildCycle(startT);
}
}
// === Drawing the top scene ===
function drawScene(t) {
const W = cv.width, H = cv.height;
ctx.clearRect(0, 0, W, H);
// background
const grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, '#06101e');
grad.addColorStop(1, '#04101a');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
// water lines
ctx.strokeStyle = 'rgba(90,209,255,0.06)';
ctx.lineWidth = 1;
for (let y = 30; y < H - 30; y += 22) {
ctx.beginPath();
ctx.moveTo(20, y);
ctx.lineTo(W - 20, y);
ctx.stroke();
}
// Map distance to x-coordinate (max distance = D_BASE+amplitude)
const maxD = D_BASE + amplitude + 200;
const xA = 70;
const xMax = W - 70;
const xOf = (d) => xA + (d / maxD) * (xMax - xA);
const yLine = H / 2 - 10;
const dTrue = trueDist(t);
const xB = xOf(dTrue);
// distance dashed line
ctx.strokeStyle = 'rgba(146,160,189,0.25)';
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(xA + 22, yLine + 50);
ctx.lineTo(xB - 22, yLine + 50);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#92a0bd';
ctx.font = '12px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillText(dTrue.toFixed(0) + ' m', (xA + xB) / 2, yLine + 66);
// Pulses for current cycle
if (cycle) {
const pulses = [
{ sendT: cycle.tPing1Send, recvT: cycle.tPing1Recv, fromA: true, color: '#5ad1ff', label: 'ping1', shape: 'solid' },
{ sendT: cycle.tPongSend, recvT: cycle.tPongRecv, fromA: false, color: '#ff8b5a', label: 'pong', shape: 'solid' },
{ sendT: cycle.tPing2Send, recvT: cycle.tPing2Recv, fromA: true, color: '#5ad1ff', label: 'ping2', shape: 'ring' },
];
for (const p of pulses) {
if (t < p.sendT || t > p.recvT) continue;
const frac = (t - p.sendT) / (p.recvT - p.sendT);
// Sender position when emitted, receiver position when reached
// For A→B: starts at xA, ends at xOf(trueDist(p.recvT))
// For B→A: starts at xOf(trueDist(p.sendT)), ends at xA
let xs, xe;
if (p.fromA) { xs = xA; xe = xOf(trueDist(p.recvT)); }
else { xs = xOf(trueDist(p.sendT)); xe = xA; }
const x = xs + (xe - xs) * frac;
ctx.beginPath();
ctx.arc(x, yLine, 7, 0, Math.PI * 2);
if (p.shape === 'ring') {
ctx.strokeStyle = p.color;
ctx.lineWidth = 2.5;
ctx.stroke();
} else {
ctx.fillStyle = p.color;
ctx.fill();
}
// trail
const trailLen = 60;
const dir = (xe > xs) ? -1 : 1;
const tx = x + dir * trailLen;
const lg = ctx.createLinearGradient(x, yLine, tx, yLine);
lg.addColorStop(0, p.color + 'cc');
lg.addColorStop(1, p.color + '00');
ctx.strokeStyle = lg;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(x, yLine);
ctx.lineTo(tx, yLine);
ctx.stroke();
// label
ctx.fillStyle = p.color;
ctx.font = '11px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillText(p.label, x, yLine - 14);
}
}
// transducers
function drawNode(x, label, color, sublabel, active) {
ctx.fillStyle = active ? color : '#1a2942';
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, yLine, 18, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = active ? '#062235' : color;
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, x, yLine);
ctx.fillStyle = '#92a0bd';
ctx.font = '11px sans-serif';
ctx.fillText(sublabel, x, yLine + 36);
}
// active flicker around send/recv events
function near(t0) { return cycle && Math.abs(t - t0) < 0.15; }
const aActive = cycle && (near(cycle.tPing1Send) || near(cycle.tPongRecv) || near(cycle.tPing2Send));
const bActive = cycle && (near(cycle.tPing1Recv) || near(cycle.tPongSend) || near(cycle.tPing2Recv));
drawNode(xA, 'A', '#5ad1ff', 'USBL A (fixe)', aActive);
drawNode(xB, 'B', '#ff8b5a', 'USBL B (mobile)', bActive);
// draw arrow showing B motion direction
const dNext = trueDist(t + 0.1);
const motion = dNext - dTrue;
if (Math.abs(motion) > 0.01) {
const arrowDir = motion > 0 ? 1 : -1;
ctx.strokeStyle = '#ff8b5a';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(xB + 22 * arrowDir, yLine - 26);
ctx.lineTo(xB + 42 * arrowDir, yLine - 26);
ctx.lineTo(xB + 36 * arrowDir, yLine - 30);
ctx.moveTo(xB + 42 * arrowDir, yLine - 26);
ctx.lineTo(xB + 36 * arrowDir, yLine - 22);
ctx.stroke();
}
}
// === Bottom plot: distance vs time ===
let plotWindow = 240; // seconds visible
function drawPlot(t) {
const W = plot.width, H = plot.height;
pctx.clearRect(0, 0, W, H);
pctx.fillStyle = '#06101e';
pctx.fillRect(0, 0, W, H);
const padL = 50, padR = 14, padT = 14, padB = 26;
const x0 = padL, x1 = W - padR, y0 = padT, y1 = H - padB;
// x range: [t-plotWindow, t]
const tMin = Math.max(0, t - plotWindow);
const tMax = Math.max(plotWindow, t);
const xOf = (tt) => x0 + (tt - tMin) / (tMax - tMin) * (x1 - x0);
// y range: distance bounds
const dMin = Math.max(0, D_BASE - amplitude - 200);
const dMax = D_BASE + amplitude + 200;
const yOf = (d) => y1 - (d - dMin) / (dMax - dMin) * (y1 - y0);
// grid + axis
pctx.strokeStyle = '#1e2a44';
pctx.lineWidth = 1;
pctx.fillStyle = '#536386';
pctx.font = '10px ui-monospace, monospace';
pctx.textAlign = 'right';
pctx.textBaseline = 'middle';
const yTicks = 4;
for (let i = 0; i <= yTicks; i++) {
const dv = dMin + (i / yTicks) * (dMax - dMin);
const y = yOf(dv);
pctx.beginPath();
pctx.moveTo(x0, y);
pctx.lineTo(x1, y);
pctx.stroke();
pctx.fillText(dv.toFixed(0), x0 - 6, y);
}
pctx.textAlign = 'center';
pctx.textBaseline = 'top';
const xTicks = 6;
for (let i = 0; i <= xTicks; i++) {
const tv = tMin + (i / xTicks) * (tMax - tMin);
const x = xOf(tv);
pctx.beginPath();
pctx.moveTo(x, y0);
pctx.lineTo(x, y1);
pctx.strokeStyle = '#13203a';
pctx.stroke();
pctx.fillStyle = '#536386';
pctx.fillText(tv.toFixed(0) + 's', x, y1 + 4);
}
// True distance curve (sample finely across visible window)
pctx.strokeStyle = '#fbbf24';
pctx.setLineDash([4, 3]);
pctx.lineWidth = 1.5;
pctx.beginPath();
const n = 200;
for (let i = 0; i <= n; i++) {
const tv = tMin + (i / n) * (tMax - tMin);
const d = trueDist(tv);
const x = xOf(tv);
const y = yOf(d);
if (i === 0) pctx.moveTo(x, y); else pctx.lineTo(x, y);
}
pctx.stroke();
pctx.setLineDash([]);
// History points: A side and B side
function plotPts(field, color) {
pctx.fillStyle = color;
for (const h of history) {
if (h[field === 'dA' ? 'tA' : 'tB'] < tMin) continue;
const tt = h[field === 'dA' ? 'tA' : 'tB'];
const d = h[field];
const x = xOf(tt);
const y = yOf(d);
pctx.beginPath();
pctx.arc(x, y, 3, 0, Math.PI * 2);
pctx.fill();
}
}
plotPts('dA', '#5ad1ff');
plotPts('dB', '#ff8b5a');
// playhead
const xP = xOf(t);
pctx.strokeStyle = 'rgba(255,255,255,0.4)';
pctx.lineWidth = 1;
pctx.beginPath();
pctx.moveTo(xP, y0);
pctx.lineTo(xP, y1);
pctx.stroke();
}
function updateReadout(t) {
document.getElementById('simT').textContent = t.toFixed(2) + ' s';
document.getElementById('dTrue').textContent = trueDist(t).toFixed(0) + ' m';
document.getElementById('nCycles').textContent = nCycles;
if (history.length > 0) {
const last = history[history.length - 1];
document.getElementById('dLastA').textContent = last.dA.toFixed(1) + ' m';
document.getElementById('dLastB').textContent = last.dB.toFixed(1) + ' m';
document.getElementById('errA').textContent = (last.dA - last.dTrueAtStart).toFixed(1) + ' m';
document.getElementById('errB').textContent = (last.dB - last.dTrueAtStart).toFixed(1) + ' m';
}
}
function tick(ts) {
if (!lastTs) lastTs = ts;
const dt = (ts - lastTs) / 1000;
lastTs = ts;
if (playing) simT += dt * speedScale;
ensureCycle();
drawScene(simT);
drawPlot(simT);
updateReadout(simT);
requestAnimationFrame(tick);
}
function reset() {
simT = 0;
cycle = null;
history.length = 0;
nCycles = 0;
lastTs = 0;
playing = false;
document.getElementById('btnPlay').textContent = '▶ Démarrer';
}
document.getElementById('btnPlay').addEventListener('click', () => {
playing = !playing;
document.getElementById('btnPlay').textContent = playing ? '⏸ Pause' : '▶ Reprendre';
});
document.getElementById('btnReset').addEventListener('click', reset);
document.getElementById('speed').addEventListener('input', (e) => { speedScale = parseFloat(e.target.value); });
document.getElementById('amp').addEventListener('input', (e) => {
amplitude = parseInt(e.target.value, 10);
});
document.getElementById('period').addEventListener('input', (e) => {
period = parseInt(e.target.value, 10);
});
reset();
ensureCycle();
requestAnimationFrame(tick);
})();
</script>
<script>
// === Multi-AUV simulation: TDMA vs Broadcast ===
(() => {
const C = 1500;
const TURNAROUND_B = 0.050;
const TURNAROUND_A = 0.080;
const COLORS = ['#ff8b5a', '#6ee7a7', '#fbbf24', '#ec4899', '#a78bfa', '#22d3ee'];
const IDS = ['0xA1', '0xA2', '0xA3', '0xA4', '0xA5', '0xA6'];
const BCAST_ID = 'BCAST';
const SLAVE_NAMES = ['AUV1', 'AUV2', 'AUV3', 'AUV4', 'AUV5', 'AUV6'];
function NAMES(i) { return SLAVE_NAMES[i]; }
function slaveHasGPS(_i) { return false; }
// Realistic AUV ops in [5, 700] m range. Max radial speed = 2π·amp/period ≈ 12 kt.
const PARAMS = [
{ base: 60, amp: 30, period: 200, phase: 0 }, // ~1.8 kt
{ base: 200, amp: 50, period: 350, phase: Math.PI / 2 }, // ~1.7 kt
{ base: 350, amp: 60, period: 400, phase: Math.PI }, // ~1.8 kt
{ base: 550, amp: 80, period: 500, phase: 3 * Math.PI / 2 }, // ~2.0 kt
{ base: 120, amp: 40, period: 250, phase: Math.PI / 4 }, // ~2.0 kt
{ base: 450, amp: 50, period: 300, phase: 5 * Math.PI / 4 }, // ~2.0 kt
];
const mode = 'addressed'; // single mode now
let nB = 4;
let speedScale = 2;
let playing = false;
let lastTs = 0;
let simT = 0;
let nCycles = 0;
const cv = document.getElementById('cv2');
const ctx = cv.getContext('2d');
const plot = document.getElementById('plot2');
const pctx = plot.getContext('2d');
const legend = document.getElementById('legend2');
const lastReadings = document.getElementById('lastReadings');
function trueDist(i, t) {
const p = PARAMS[i];
const d = p.base + p.amp * Math.sin(2 * Math.PI * t / p.period + p.phase);
return Math.max(5, d);
}
function legArrival(senderIsA, sendT, bIdx) {
if (!senderIsA) return sendT + trueDist(bIdx, sendT) / C;
let t = sendT + trueDist(bIdx, sendT) / C;
for (let i = 0; i < 6; i++) t = sendT + trueDist(bIdx, t) / C;
return t;
}
// === Addressed cycle SS-TWR + passive listening ===
// The addressed AUV does ping+pong (active TWR). All other AUVs hear the ping
// and compute their own range to USV passively (one-way ranging, OWR), assuming
// they share a synchronized time reference with the USV (or the ping carries the
// USV transmit timestamp). One ping → N range measurements.
function buildCycleAddressed(startT, bIdx) {
const tPingSend = startT;
// Active leg (addressed AUV): full ping + pong
const tPingRecv = legArrival(true, tPingSend, bIdx);
const tPongSend = tPingRecv + TURNAROUND_B;
const tPongRecv = legArrival(false, tPongSend, bIdx);
const TOF = (tPongRecv - tPingSend - TURNAROUND_B) / 2;
const dist_A = TOF * C;
const legs = [{
bIdx,
passive: false,
tPingSend, tPingRecv,
tPongSend, tPongRecv,
tPing2Send: tPongRecv, tPing2Recv: tPongRecv,
d_at_start: trueDist(bIdx, tPingSend),
dist_A, dist_B: dist_A,
}];
// Passive legs: every other AUV measures its own ping arrival → range
for (let i = 0; i < nB; i++) {
if (i === bIdx) continue;
const tRecv = legArrival(true, tPingSend, i);
const distPassive = (tRecv - tPingSend) * C;
legs.push({
bIdx: i,
passive: true,
tPingSend, tPingRecv: tRecv,
tPongSend: tRecv, tPongRecv: tRecv,
tPing2Send: tRecv, tPing2Recv: tRecv,
d_at_start: trueDist(i, tPingSend),
dist_A: distPassive, dist_B: distPassive,
});
}
return {
mode: 'addressed',
targetIdx: bIdx,
targetId: IDS[bIdx],
tStart: tPingSend,
tEnd: tPongRecv,
tBroadcastPing: tPingSend,
tBroadcastPing2: tPongRecv,
legs,
};
}
let cycle = null;
const histories = [[], [], [], [], [], []];
function ensureCycle() {
if (!cycle || simT >= cycle.tEnd) {
if (cycle && !cycle.recorded) recordCycle(cycle);
const bIdx = (cycle ? (cycle.legs[0].bIdx + 1) % nB : 0);
const startT = cycle ? cycle.tEnd : 0;
cycle = buildCycleAddressed(startT, bIdx);
cycle.recorded = false;
}
if (cycle && !cycle.recorded && simT >= cycle.tEnd) {
recordCycle(cycle);
}
}
function recordCycle(c) {
for (const leg of c.legs) {
histories[leg.bIdx].push({
tA: leg.tPongRecv,
tB: leg.tPing2Recv,
dA: leg.dist_A,
dB: leg.dist_B,
dTrue: leg.d_at_start,
passive: !!leg.passive,
});
if (histories[leg.bIdx].length > 200) histories[leg.bIdx].shift();
}
c.recorded = true;
nCycles++;
}
function buildLegend() {
legend.innerHTML = '';
legend.appendChild(makeLegendItem('USV master (surface · GPS)', '#5ad1ff'));
for (let i = 0; i < nB; i++) {
legend.appendChild(makeLegendItem(NAMES(i) + ' (immergé)', COLORS[i]));
}
}
function makeLegendItem(label, color) {
const span = document.createElement('span');
span.innerHTML = `<span class="sw" style="background:${color}"></span>${label}`;
return span;
}
function buildReadingsLayout() {
lastReadings.innerHTML = '';
for (let i = 0; i < nB; i++) {
const k = document.createElement('div'); k.className = 'k';
k.innerHTML = `<span class="sw" style="background:${COLORS[i]};display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:6px;"></span>${NAMES(i)} dernière mesure`;
const v = document.createElement('div'); v.className = 'v'; v.id = 'last_' + i;
v.textContent = '— m';
lastReadings.appendChild(k);
lastReadings.appendChild(v);
}
}
// === Drawing ===
function phaseLabel(t) {
if (!cycle) return '—';
if (t < cycle.tStart) return 'idle';
const leg = cycle.legs[0];
const id = IDS[leg.bIdx];
if (t < leg.tPingRecv) return `ping[${id}] → ${NAMES(leg.bIdx)} (autres ignorent)`;
if (t < leg.tPongSend) return `${NAMES(leg.bIdx)} traite (match ID)`;
if (t < leg.tPongRecv) return `pong ${NAMES(leg.bIdx)} → USV`;
return 'USV calcule la distance';
}
function drawScene(t) {
const W = cv.width, H = cv.height;
ctx.clearRect(0, 0, W, H);
const grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, '#06101e');
grad.addColorStop(1, '#04101a');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = 'rgba(90,209,255,0.05)';
for (let y = 30; y < H - 30; y += 22) {
ctx.beginPath(); ctx.moveTo(20, y); ctx.lineTo(W - 20, y); ctx.stroke();
}
// Phase header
const headerY = 22;
ctx.fillStyle = '#92a0bd';
ctx.font = '11px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`USV master ↔ ${nB} AUVs — Adressage séquentiel — phase : `, 20, headerY);
ctx.fillStyle = '#5ad1ff';
ctx.font = 'bold 11px ui-monospace, monospace';
const phLabel = phaseLabel(t);
ctx.fillText(phLabel, 380, headerY);
// Cycle progress bar
if (cycle) {
const barY = 36, barH = 6, barX = 20, barW = W - 40;
ctx.fillStyle = '#1a2942';
ctx.fillRect(barX, barY, barW, barH);
const cycleDur = cycle.tEnd - cycle.tStart;
const p = Math.max(0, Math.min(1, (t - cycle.tStart) / cycleDur));
ctx.fillStyle = '#5ad1ff';
ctx.fillRect(barX, barY, barW * p, barH);
}
const sceneTop = 60;
const xA = 90;
const maxD = Math.max(...PARAMS.slice(0, nB).map(p => p.base + p.amp)) + 300;
const xMax = W - 90;
const xOf = (d) => xA + (d / maxD) * (xMax - xA);
const rowH = (H - sceneTop - 30) / nB;
const yA = sceneTop + (H - sceneTop - 30) / 2;
// Determine which B's are "active" (pulse currently in flight to/from)
function isLegActiveNow(leg) {
return (t >= leg.tPingSend && t <= leg.tPing2Recv);
}
// Draw each B
const targetIdx = cycle ? cycle.targetIdx : -1; // -1 = broadcast (all match)
for (let i = 0; i < nB; i++) {
const yB = sceneTop + rowH * (i + 0.5);
const d = trueDist(i, t);
const xB = xOf(d);
const isTarget = (cycle && cycle.mode === 'addressed') ? (targetIdx === i) : true;
ctx.strokeStyle = COLORS[i] + '40';
ctx.setLineDash([3, 3]);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(xA + 16, yA);
ctx.lineTo(xB - 14, yB);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = COLORS[i] + 'aa';
ctx.font = '10px ui-monospace, monospace';
ctx.textAlign = 'left';
ctx.fillText(d.toFixed(0) + ' m', xB + 16, yB + 4);
const leg = cycle ? cycle.legs.find(l => l.bIdx === i) : null;
const isActive = leg && isLegActiveNow(leg) && isTarget;
ctx.globalAlpha = isTarget ? 1 : 0.85;
ctx.fillStyle = isActive ? COLORS[i] : '#1a2942';
ctx.strokeStyle = COLORS[i];
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(xB, yB, 13, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.fillStyle = isActive ? '#062235' : COLORS[i];
ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(NAMES(i), xB, yB);
ctx.globalAlpha = 1;
// ID + GPS badge (only if slave has GPS, e.g. USV in slave list)
ctx.fillStyle = isTarget ? '#92a0bd' : '#536386';
ctx.font = '9px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillText(IDS[i], xB, yB + 24);
if (slaveHasGPS(i)) {
ctx.fillStyle = isTarget ? '#6ee7a7' : '#3a7a5a';
ctx.font = 'bold 8px sans-serif';
ctx.fillText('GPS', xB - 22, yB - 14);
}
// Match indicator when ping reaches each B
if (cycle) {
const dB = trueDist(i, t);
const tArrival = cycle.tBroadcastPing + dB / C;
if (Math.abs(t - tArrival) < 0.8 && t >= tArrival) {
ctx.fillStyle = isTarget ? '#6ee7a7' : '#5ad1ff';
ctx.font = 'bold 11px ui-monospace, monospace';
ctx.textAlign = 'left';
ctx.fillText(isTarget ? '✓ active TWR' : '📡 passive OWR', xB + 16, yB - 8);
}
}
}
// Pulses — iterate over all legs (real responding paths)
if (cycle) {
// (No ghost block needed — the per-leg loop below already draws the ping
// pulse on every AUV's line, since passive legs are real measurements.)
const targetLabel = `[${cycle.targetId}]`;
for (const leg of cycle.legs) {
const yB = sceneTop + rowH * (leg.bIdx + 0.5);
const pulses = [
{ sendT: leg.tPingSend, recvT: leg.tPingRecv, fromA: true, color: '#5ad1ff', label: 'ping' + targetLabel },
{ sendT: leg.tPongSend, recvT: leg.tPongRecv, fromA: false, color: COLORS[leg.bIdx], label: 'pong[' + IDS[leg.bIdx] + ']' },
];
for (const p of pulses) {
if (t < p.sendT || t > p.recvT) continue;
if (p.recvT === p.sendT) continue; // skip zero-duration pulses
const frac = (t - p.sendT) / (p.recvT - p.sendT);
let xs, ys, xe, ye;
if (p.fromA) {
xs = xA; ys = yA;
xe = xOf(trueDist(leg.bIdx, p.recvT)); ye = yB;
} else {
xs = xOf(trueDist(leg.bIdx, p.sendT)); ys = yB;
xe = xA; ye = yA;
}
const x = xs + (xe - xs) * frac;
const y = ys + (ye - ys) * frac;
ctx.beginPath();
ctx.arc(x, y, 6, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.fill();
if (frac < 0.85) {
ctx.fillStyle = p.color;
ctx.font = '9px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillText(p.label, x, y - 10);
}
}
}
}
// Master node (USV or AUV depending on role)
const aGlow = cycle && (
Math.abs(t - cycle.tBroadcastPing) < 0.2 ||
Math.abs(t - cycle.tBroadcastPing2) < 0.2
);
ctx.fillStyle = '#5ad1ff';
ctx.strokeStyle = aGlow ? '#fff' : '#5ad1ff';
ctx.lineWidth = aGlow ? 3 : 2;
ctx.beginPath(); ctx.arc(xA, yA, 22, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.fillStyle = '#062235';
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('USV', xA, yA);
ctx.fillStyle = '#92a0bd';
ctx.font = '9px ui-monospace, monospace';
ctx.fillText('master · surface', xA, yA + 32);
ctx.fillStyle = '#6ee7a7';
ctx.font = 'bold 8px sans-serif';
ctx.fillText('GPS', xA, yA + 44);
}
let plotWindow = 240;
function drawPlot(t) {
const W = plot.width, H = plot.height;
pctx.clearRect(0, 0, W, H);
pctx.fillStyle = '#06101e'; pctx.fillRect(0, 0, W, H);
const padL = 50, padR = 14, padT = 14, padB = 26;
const x0 = padL, x1 = W - padR, y0 = padT, y1 = H - padB;
const tMin = Math.max(0, t - plotWindow);
const tMax = Math.max(plotWindow, t);
const xOf = (tt) => x0 + (tt - tMin) / (tMax - tMin) * (x1 - x0);
const dMin = Math.min(...PARAMS.slice(0, nB).map(p => p.base - p.amp)) - 80;
const dMax = Math.max(...PARAMS.slice(0, nB).map(p => p.base + p.amp)) + 80;
const yOf = (d) => y1 - (d - dMin) / (dMax - dMin) * (y1 - y0);
// grid + labels
pctx.strokeStyle = '#1e2a44'; pctx.lineWidth = 1;
pctx.fillStyle = '#536386'; pctx.font = '10px ui-monospace, monospace';
pctx.textAlign = 'right'; pctx.textBaseline = 'middle';
for (let i = 0; i <= 4; i++) {
const dv = dMin + (i / 4) * (dMax - dMin);
const y = yOf(dv);
pctx.beginPath(); pctx.moveTo(x0, y); pctx.lineTo(x1, y); pctx.stroke();
pctx.fillText(dv.toFixed(0), x0 - 6, y);
}
pctx.textAlign = 'center'; pctx.textBaseline = 'top';
for (let i = 0; i <= 6; i++) {
const tv = tMin + (i / 6) * (tMax - tMin);
const x = xOf(tv);
pctx.strokeStyle = '#13203a';
pctx.beginPath(); pctx.moveTo(x, y0); pctx.lineTo(x, y1); pctx.stroke();
pctx.fillStyle = '#536386';
pctx.fillText(tv.toFixed(0) + 's', x, y1 + 4);
}
// True curves per B (dashed)
for (let i = 0; i < nB; i++) {
pctx.strokeStyle = COLORS[i] + '55';
pctx.setLineDash([3, 3]);
pctx.lineWidth = 1;
pctx.beginPath();
const n = 200;
for (let k = 0; k <= n; k++) {
const tv = tMin + (k / n) * (tMax - tMin);
const d = trueDist(i, tv);
const x = xOf(tv); const y = yOf(d);
if (k === 0) pctx.moveTo(x, y); else pctx.lineTo(x, y);
}
pctx.stroke();
}
pctx.setLineDash([]);
// Measurement points (use A side)
for (let i = 0; i < nB; i++) {
pctx.fillStyle = COLORS[i];
for (const h of histories[i]) {
if (h.tA < tMin) continue;
const x = xOf(h.tA); const y = yOf(h.dA);
pctx.beginPath(); pctx.arc(x, y, 3, 0, Math.PI * 2); pctx.fill();
}
}
// playhead
const xP = xOf(t);
pctx.strokeStyle = 'rgba(255,255,255,0.4)'; pctx.lineWidth = 1;
pctx.beginPath(); pctx.moveTo(xP, y0); pctx.lineTo(xP, y1); pctx.stroke();
}
function updateReadout(t) {
document.getElementById('simT2').textContent = t.toFixed(2) + ' s';
document.getElementById('curSlot').textContent = phaseLabel(t);
document.getElementById('nCycles2').textContent = nCycles;
if (cycle) {
const cur = cycle.tEnd - cycle.tStart;
document.getElementById('fullCycleDur').textContent = cur.toFixed(2) + ' s';
// estimate full scan = N sequential cycles using base distances
let total = 0;
for (let i = 0; i < nB; i++) {
total += 3 * (PARAMS[i].base / C) + TURNAROUND_B + TURNAROUND_A;
}
document.getElementById('cmpDur').textContent = total.toFixed(2) + ' s';
}
for (let i = 0; i < nB; i++) {
const el = document.getElementById('last_' + i);
if (!el) continue;
const h = histories[i];
el.textContent = h.length ? `${h[h.length-1].dA.toFixed(1)} m (vraie ${h[h.length-1].dTrue.toFixed(0)})` : '— m';
}
}
function tick(ts) {
if (!lastTs) lastTs = ts;
const dt = (ts - lastTs) / 1000;
lastTs = ts;
if (playing) simT += dt * speedScale;
ensureCycle();
drawScene(simT);
drawPlot(simT);
updateReadout(simT);
requestAnimationFrame(tick);
}
function reset() {
simT = 0;
cycle = null;
nCycles = 0;
for (let i = 0; i < histories.length; i++) histories[i].length = 0;
lastTs = 0;
playing = false;
document.getElementById('btnPlay2').textContent = '▶ Démarrer';
buildLegend();
buildReadingsLayout();
}
document.getElementById('btnPlay2').addEventListener('click', () => {
playing = !playing;
document.getElementById('btnPlay2').textContent = playing ? '⏸ Pause' : '▶ Reprendre';
});
document.getElementById('btnReset2').addEventListener('click', reset);
document.getElementById('speed2').addEventListener('input', e => { speedScale = parseFloat(e.target.value); });
document.getElementById('nB').addEventListener('input', e => {
nB = parseInt(e.target.value, 10);
document.getElementById('nBLabel').textContent = nB;
reset();
});
reset();
ensureCycle();
requestAnimationFrame(tick);
})();
</script>
</body>
</html>