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.
1129 lines
45 KiB
HTML
1129 lines
45 KiB
HTML
<!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 :
|
||
</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 : 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 : 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 s à 3 km). Visible quand la période d'oscillation est courte.</li>
|
||
<li><b>Biais lié au mouvement de B : négligeable en pratique</b> — pour un AUV à 1 kt (≈ 0,5 m/s) et c = 1500 m/s, B se déplace ~1 m pendant un TOF de 2 s à 3 km. Très en-dessous du bruit USBL natif (typiquement 0,5–2 % de la slant range, soit 15–60 m à 3 km). Même à 5 kt c'est ~5 m, encore noyé dans le bruit. Donc en ops AUV typiques (1–3 kt), <b>oublier ce biais</b> ; il ne devient significatif qu'au-delà de ~10 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 : 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 ; seul l'AUV adressé répond avec son pong. <b>Pas de ping2</b> : 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 / AOA pré-mission, ou ping qui embarque <code>T_send</code> dans sa trame). Chacun calcule : <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 :</b> 1 ping → <b>N mesures</b> de distance USV↔AUV (1 active TWR pour la cible, N−1 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 : tous les AUVs affichent <span style="color:#5ad1ff">📡 passive OWR</span> ou <span style="color:#6ee7a7">✓ 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 : <b>3000 m</b>. Célérité : <b>1500 m/s</b> → TOF unidirectionnel = <b>2.000 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 % de cycle en plus. À 3 km, ~6 s par mesure (DS-TWR symétrique) vs ~4 s pour SS-TWR. Inadapté à un tracking rapide haut débit ; 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 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 : célérité 1500 m/s vs 3·10⁸ 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 ≈ 1–2 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>
|