kevinconka commited on
Commit
e5c56d3
Β·
1 Parent(s): 035d7e4

Refactor app.js to replace canvas with SVG for rendering, enhancing visual fidelity and performance. Introduced new SVG element creation functions and updated related rendering logic. Removed the legend section from index.html and adjusted styles in style.css to accommodate the SVG implementation.

Browse files
Files changed (3) hide show
  1. app.js +630 -331
  2. index.html +1 -21
  3. style.css +13 -43
app.js CHANGED
@@ -5,9 +5,14 @@
5
  return document.getElementById(id)
6
  }
7
 
8
- var canvas = null
 
 
 
 
 
 
9
  var wrap = null
10
- var ctx = null
11
  var W = 0
12
  var H = 0
13
  var camCX = 0
@@ -15,6 +20,40 @@
15
  var SCALE = 0
16
  var zoomLevel = 1.0
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  var boatStart = { x: 0.0, y: 1.0 }
19
  var boatEnd = { x: 0.0, y: 0.15 }
20
  var dragging = null
@@ -34,7 +73,33 @@
34
  var lastPinchDist = null
35
  var rafId = 0
36
  var running = false
37
- var camDir = -1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
  // Color palette β€” loaded from CSS custom properties at init
40
  var C = {
@@ -92,6 +157,21 @@
92
  C.wc = v('--warning-content')
93
  C.hi = v('--content-hi')
94
  C.lo = v('--content-lo')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  }
96
 
97
  function rgba(triplet, alpha) {
@@ -108,15 +188,329 @@
108
  prevVisible = null
109
  }
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  function resize() {
112
- if (!wrap || !canvas) return
113
  W = wrap.clientWidth
114
  H = wrap.clientHeight
115
- canvas.width = W
116
- canvas.height = H
117
  camCX = W / 2
118
  camCY = H * 0.88
119
  SCALE = Math.min(W, H) * 0.42 * zoomLevel
 
 
 
 
 
 
 
 
 
 
 
120
  }
121
 
122
  function updateZoomLabel() {
@@ -124,20 +518,39 @@
124
  if (el) el.textContent = Math.round(zoomLevel * 100) + '%'
125
  }
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  function applyZoom(delta) {
128
  var factor = delta > 0 ? 1.12 : 1 / 1.12
129
  zoomLevel = Math.max(0.3, Math.min(8, zoomLevel * factor))
130
  SCALE = Math.min(W, H) * 0.42 * zoomLevel
 
 
131
  updateZoomLabel()
132
  }
133
 
134
  function getParams() {
135
  return {
136
- fov: +$('input-fov').value,
137
- frontSpeed: +$('input-front-speed').value,
138
- backSpeed: +$('input-back-speed').value,
139
- frontArc: +$('input-front-arc').value,
140
- boatSpeed: +$('input-boat-speed').value,
141
  }
142
  }
143
 
@@ -176,329 +589,112 @@
176
  return Math.abs(rel) <= fovVal / 2 && by > -0.05
177
  }
178
 
179
- function drawDirIndicator(d) {
180
- if (!ctx) return
181
- var cx = 30,
182
- cy = 30,
183
- r = 14
184
- ctx.beginPath()
185
- ctx.arc(cx, cy, r, 0, Math.PI * 2)
186
- ctx.strokeStyle = rgba(C.pr, 0.2)
187
- ctx.lineWidth = 1
188
- ctx.stroke()
189
- var start = -Math.PI / 2
190
- var end = start + (d > 0 ? 1.2 : -1.2)
191
- ctx.beginPath()
192
- ctx.arc(cx, cy, r - 3, start, end, d < 0)
193
- ctx.strokeStyle = rgba(C.pr, 0.7)
194
- ctx.lineWidth = 2
195
- ctx.stroke()
196
- var tx = cx + Math.cos(end) * (r - 3)
197
- var ty = cy + Math.sin(end) * (r - 3)
198
- ctx.beginPath()
199
- ctx.arc(tx, ty, 2, 0, Math.PI * 2)
200
- ctx.fillStyle = rgba(C.pr, 0.7)
201
- ctx.fill()
202
- ctx.fillStyle = C.pc
203
- ctx.font = "12px Saira Condensed, Arial Narrow, Arial, sans-serif"
204
- ctx.textAlign = 'center'
205
- ctx.fillText(d > 0 ? 'CW' : 'CCW', cx, cy + 3)
206
- }
207
-
208
  function draw(p, bx, by, inFov) {
209
- if (!ctx) return
210
- ctx.clearRect(0, 0, W, H)
211
-
212
- ctx.fillStyle = C.s1
213
- ctx.fillRect(0, 0, W, H)
214
- ctx.fillStyle = C.s2
215
- ctx.fillRect(0, camCY, W, H - camCY)
216
-
217
- ctx.beginPath()
218
- ctx.moveTo(0, camCY)
219
- ctx.lineTo(W, camCY)
220
- ctx.strokeStyle = rgba(C.pr, 0.4)
221
- ctx.lineWidth = 1
222
- ctx.setLineDash([6, 4])
223
- ctx.stroke()
224
- ctx.setLineDash([])
225
-
226
- var niceIntervals = [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0]
227
- var targetMinPx = 100
228
- var targetMaxPx = 240
229
- var ringInterval = niceIntervals[0]
230
- for (var i = 0; i < niceIntervals.length; i++) {
231
- var iv = niceIntervals[i]
232
- var px = iv * SCALE
233
- if (px >= targetMinPx && px <= targetMaxPx) {
234
- ringInterval = iv
235
- break
236
- }
237
- if (px < targetMinPx) ringInterval = iv
238
- }
239
- var maxDist = Math.sqrt(Math.pow(Math.max(camCX, W - camCX), 2) + Math.pow(camCY, 2))
240
- var maxRingCount = Math.ceil(maxDist / (ringInterval * SCALE)) + 1
241
- function fmtDist(d) {
242
- return d < 1 ? Math.round(d * 1000) + 'm' : d.toFixed(d < 10 ? 2 : 1).replace(/\.0+$/, '') + 'km'
243
  }
 
 
244
 
245
- for (var ri = 1; ri <= maxRingCount; ri++) {
246
- var r = ri * ringInterval * SCALE
247
- ctx.beginPath()
248
- ctx.arc(camCX, camCY, r, 0, Math.PI * 2)
249
- ctx.strokeStyle = rgba(C.pr, 0.25)
250
- ctx.lineWidth = 0.5
251
- ctx.stroke()
252
- ctx.beginPath()
253
- ctx.moveTo(camCX - 5, camCY - r)
254
- ctx.lineTo(camCX + 5, camCY - r)
255
- ctx.strokeStyle = rgba(C.pr, 0.7)
256
- ctx.lineWidth = 1
257
- ctx.stroke()
258
- ctx.fillStyle = C.pc
259
- ctx.font = "12px Saira Condensed, Arial Narrow, Arial, sans-serif"
260
- ctx.textAlign = 'left'
261
- ctx.fillText(fmtDist(ri * ringInterval), camCX + 8, camCY - r + 4)
262
- }
263
 
264
- var compassR = Math.min(camCX * 0.88, camCY * 0.92)
265
- var canvasBase = -Math.PI / 2
266
- for (var deg = 0; deg < 360; deg += 10) {
267
- var a = canvasBase + (deg * Math.PI) / 180
268
- var isMajor = deg % 30 === 0
269
- var r0 = compassR
270
- var r1 = compassR + (isMajor ? 8 : 4)
271
- ctx.beginPath()
272
- ctx.moveTo(camCX + Math.cos(a) * r0, camCY + Math.sin(a) * r0)
273
- ctx.lineTo(camCX + Math.cos(a) * r1, camCY + Math.sin(a) * r1)
274
- ctx.strokeStyle = isMajor ? rgba(C.pr, 0.65) : rgba(C.pr, 0.35)
275
- ctx.lineWidth = isMajor ? 1 : 0.5
276
- ctx.stroke()
277
- if (isMajor) {
278
- var lx = camCX + Math.cos(a) * (r1 + 10)
279
- var ly = camCY + Math.sin(a) * (r1 + 10)
280
- ctx.fillStyle = C.lo
281
- ctx.font = "11px Saira Condensed, Arial Narrow, Arial, sans-serif"
282
- ctx.textAlign = 'center'
283
- ctx.textBaseline = 'middle'
284
- ctx.fillText(deg + 'Β°', lx, ly)
285
- ctx.textBaseline = 'alphabetic'
286
- }
287
  }
288
 
289
- ctx.beginPath()
290
- ctx.moveTo(camCX, camCY)
291
- ctx.lineTo(camCX, 0)
292
- ctx.strokeStyle = rgba(C.pr, 0.2)
293
- ctx.lineWidth = 0.5
294
- ctx.setLineDash([4, 4])
295
- ctx.stroke()
296
- ctx.setLineDash([])
297
-
298
- var frontHalf = p.frontArc / 2
299
- var frontA1 = canvasBase - (frontHalf * Math.PI) / 180
300
- var frontA2 = canvasBase + (frontHalf * Math.PI) / 180
301
- var INF = Math.max(W, H) * 3
302
- ctx.save()
303
- ctx.beginPath()
304
- ctx.moveTo(camCX, camCY)
305
- ctx.lineTo(camCX + Math.cos(frontA1) * INF, camCY + Math.sin(frontA1) * INF)
306
- ctx.arc(camCX, camCY, INF, frontA1, frontA2)
307
- ctx.closePath()
308
- ctx.fillStyle = rgba(C.pr, 0.07)
309
- ctx.fill()
310
- ctx.beginPath()
311
- ctx.moveTo(camCX, camCY)
312
- ctx.lineTo(camCX + Math.cos(frontA2) * INF, camCY + Math.sin(frontA2) * INF)
313
- ctx.arc(camCX, camCY, INF, frontA2, frontA1 + Math.PI * 2)
314
- ctx.closePath()
315
- ctx.fillStyle = rgba(C.dr, 0.06)
316
- ctx.fill()
317
- ctx.restore()
318
-
319
- for (var ai = 0; ai < 2; ai++) {
320
- var aedge = ai === 0 ? frontA1 : frontA2
321
- ctx.beginPath()
322
- ctx.moveTo(camCX, camCY)
323
- ctx.lineTo(camCX + Math.cos(aedge) * INF, camCY + Math.sin(aedge) * INF)
324
- ctx.strokeStyle = rgba(C.pr, 0.5)
325
- ctx.lineWidth = 1
326
- ctx.setLineDash([5, 5])
327
- ctx.stroke()
328
- ctx.setLineDash([])
329
  }
330
 
331
- var labelR = 80
332
- ctx.font = "11px Saira Condensed, Arial Narrow, Arial, sans-serif"
333
- ctx.textAlign = 'center'
334
- ctx.fillStyle = C.pc
335
- ctx.fillText('FRONT ' + p.frontArc + 'Β°', camCX, camCY - labelR)
336
- ctx.fillStyle = C.dc
337
- if (camCY + 18 < H - 2) ctx.fillText('BACK ' + (360 - p.frontArc) + 'Β°', camCX, camCY + 18)
338
- ctx.fillStyle = C.lo
339
- ctx.fillText('Β±' + frontHalf.toFixed(0) + 'Β°', camCX + Math.cos(frontA1) * labelR, camCY + Math.sin(frontA1) * labelR)
340
- ctx.fillText('Β±' + frontHalf.toFixed(0) + 'Β°', camCX + Math.cos(frontA2) * labelR, camCY + Math.sin(frontA2) * labelR)
341
-
342
- var camRad = (camHeading * Math.PI) / 180
343
- var fovRad = (p.fov * Math.PI) / 180
344
- var coneLen = Math.max(W, H) * 2
345
- ctx.beginPath()
346
- ctx.moveTo(camCX, camCY)
347
- ctx.arc(camCX, camCY, coneLen, canvasBase + camRad - fovRad / 2, canvasBase + camRad + fovRad / 2)
348
- ctx.closePath()
349
- ctx.fillStyle = rgba(C.pr, 0.12)
350
- ctx.fill()
351
- ctx.beginPath()
352
- ctx.moveTo(camCX, camCY)
353
- ctx.arc(camCX, camCY, coneLen, canvasBase + camRad - fovRad / 2, canvasBase + camRad + fovRad / 2)
354
- ctx.closePath()
355
- ctx.strokeStyle = rgba(C.pr, 0.6)
356
- ctx.lineWidth = 1
357
- ctx.stroke()
358
-
359
- ctx.beginPath()
360
- ctx.moveTo(camCX, camCY)
361
- ctx.lineTo(camCX + Math.cos(canvasBase + camRad) * 32, camCY + Math.sin(canvasBase + camRad) * 32)
362
- ctx.strokeStyle = rgba(C.pr, 0.9)
363
- ctx.lineWidth = 1.5
364
- ctx.stroke()
365
-
366
- var bC = worldToCanvas(bx, by)
367
- var bsC = worldToCanvas(boatStart.x, boatStart.y)
368
- var beC = worldToCanvas(boatEnd.x, boatEnd.y)
369
- ctx.beginPath()
370
- ctx.moveTo(bsC.x, bsC.y)
371
- ctx.lineTo(beC.x, beC.y)
372
- ctx.strokeStyle = rgba(C.dr, 0.25)
373
- ctx.lineWidth = 1
374
- ctx.setLineDash([4, 3])
375
- ctx.stroke()
376
- ctx.setLineDash([])
377
-
378
- ctx.beginPath()
379
- ctx.moveTo(camCX, camCY)
380
- ctx.lineTo(bC.x, bC.y)
381
- ctx.strokeStyle = rgba(C.dr, 0.12)
382
- ctx.lineWidth = 0.5
383
- ctx.stroke()
384
-
385
  if (inFov) {
386
- ctx.beginPath()
387
- ctx.arc(bC.x, bC.y, 14, 0, Math.PI * 2)
388
- ctx.fillStyle = rgba(C.sr, 0.12)
389
- ctx.fill()
390
  }
 
391
 
392
- ctx.beginPath()
393
- ctx.arc(bC.x, bC.y, 5, 0, Math.PI * 2)
394
- ctx.fillStyle = inFov ? C.s : C.d
395
- ctx.fill()
396
- ctx.strokeStyle = inFov ? rgba(C.sr, 0.5) : rgba(C.dr, 0.4)
397
- ctx.lineWidth = 1.5
398
- ctx.stroke()
399
-
400
- ctx.font = "11px Saira Condensed, Arial Narrow, Arial, sans-serif"
401
- ctx.textAlign = 'left'
402
- ctx.fillStyle = inFov ? C.sc : C.dc
403
- ctx.fillText('TGT', bC.x + 8, bC.y - 4)
404
-
405
- ctx.beginPath()
406
- ctx.arc(bsC.x, bsC.y, 4, 0, Math.PI * 2)
407
- ctx.fillStyle = C.d
408
- ctx.fill()
409
- ctx.beginPath()
410
- ctx.arc(beC.x, beC.y, 4, 0, Math.PI * 2)
411
- ctx.fillStyle = C.w
412
- ctx.fill()
413
- ctx.font = "11px Saira Condensed, Arial Narrow, Arial, sans-serif"
414
- ctx.fillStyle = C.dc
415
- ctx.textAlign = 'center'
416
- ctx.fillText('S', bsC.x, bsC.y + 14)
417
- ctx.fillStyle = C.wc
418
- ctx.fillText('E', beC.x, beC.y + 14)
419
-
420
- var cr = 8
421
- ctx.beginPath()
422
- ctx.arc(camCX, camCY, cr, 0, Math.PI * 2)
423
- ctx.fillStyle = C.p
424
- ctx.fill()
425
- ctx.strokeStyle = rgba(C.pr, 0.5)
426
- ctx.lineWidth = 1.5
427
- ctx.stroke()
428
- var corners = [
429
- [-14, -14],
430
- [14, -14],
431
- [14, 14],
432
- [-14, 14],
433
- ]
434
- for (var ci = 0; ci < corners.length; ci++) {
435
- var dx = corners[ci][0],
436
- dy = corners[ci][1]
437
- ctx.beginPath()
438
- ctx.moveTo(camCX + dx * 0.4, camCY + dy * 0.4)
439
- ctx.lineTo(camCX + dx, camCY + dy)
440
- ctx.strokeStyle = rgba(C.pr, 0.5)
441
- ctx.lineWidth = 1
442
- ctx.stroke()
443
- }
444
 
445
- drawDirIndicator(camDir)
 
 
 
 
 
 
446
 
 
447
  var dist = Math.sqrt(bx * bx + by * by)
448
  var h360 = ((camHeading % 360) + 360) % 360
449
  var st = scanTime(p)
450
-
451
- function setText(id, text) {
452
- var el = $(id)
453
- if (el) el.textContent = text
454
- }
455
-
456
- setText('st-scan', st.toFixed(1) + 's')
457
- setText('st-angle', h360.toFixed(1) + 'Β°')
458
- setText('st-dist', (dist * 1000).toFixed(0) + 'm')
459
-
460
- var travelEl = $('st-travel')
461
- var blindEl = $('st-blind')
462
- var travelPrevEl = $('st-travel-prev')
463
- var blindPrevEl = $('st-blind-prev')
464
- var riskEl = $('st-risk')
465
- var riskBarEl = $('st-risk-bar')
466
- if (travelEl) {
467
- travelEl.textContent = (inFov ? detectedStreak : lastDetectedStreak).toFixed(1) + 'm'
468
- travelEl.style.opacity = inFov ? '1' : '0.5'
469
  }
470
- if (blindEl) {
471
- blindEl.textContent = (!inFov ? blindStreak : lastBlindStreak).toFixed(1) + 'm'
472
- blindEl.style.opacity = !inFov ? '1' : '0.5'
 
473
  }
474
- if (travelPrevEl)
475
- travelPrevEl.textContent =
476
- prevDetectedStreak > 0 ? 'prev: ' + prevDetectedStreak.toFixed(1) + 'm' : 'prev: β€”'
477
- if (blindPrevEl)
478
- blindPrevEl.textContent =
479
- prevBlindStreak > 0 ? 'prev: ' + prevBlindStreak.toFixed(1) + 'm' : 'prev: β€”'
480
  var total = prevDetectedStreak + prevBlindStreak
481
- if (riskEl && riskBarEl) {
482
  if (total > 0) {
483
  var risk = (prevBlindStreak / total) * 100
484
- riskEl.textContent = risk.toFixed(0) + '%'
485
- riskEl.style.color = risk > 66 ? 'var(--danger)' : risk > 33 ? 'var(--warning)' : 'var(--success)'
486
- riskBarEl.style.width = risk + '%'
487
- riskBarEl.style.background = risk > 66 ? 'var(--danger)' : risk > 33 ? 'var(--warning)' : 'var(--success)'
 
 
 
488
  } else {
489
- riskEl.textContent = 'β€”'
490
- riskBarEl.style.width = '0%'
491
  }
492
  }
493
- var visEl = $('st-vis')
494
- if (visEl) {
495
- visEl.textContent = inFov ? 'YES' : 'NO'
496
- visEl.className = 'val ' + (inFov ? 'active' : 'inactive')
 
497
  }
498
  }
499
 
500
  function animate(ts) {
501
- if (!running || !ctx) return
 
502
  if (lastTime === null) lastTime = ts
503
  var dt = Math.min((ts - lastTime) / 1000, 0.1)
504
  lastTime = ts
@@ -522,7 +718,7 @@
522
 
523
  var front = isFront(camHeading, p.frontArc)
524
  var speed = front ? p.frontSpeed : p.backSpeed
525
- camHeading += camDir * speed * dt
526
  if (camHeading >= 180) camHeading -= 360
527
  if (camHeading < -180) camHeading += 360
528
 
@@ -546,25 +742,31 @@
546
  else blindStreak += distThisFrame
547
  prevVisible = visible
548
 
549
- draw(p, bx, by, visible)
 
 
 
 
 
550
  if (running) rafId = requestAnimationFrame(animate)
551
  }
552
 
553
  function getCanvasXY(e) {
554
- if (!canvas) return { x: 0, y: 0 }
555
- var rect = canvas.getBoundingClientRect()
556
- var sx = W / rect.width
557
- var sy = H / rect.height
558
  var src = 'touches' in e && e.touches.length ? e.touches[0] : e
559
- return { x: (src.clientX - rect.left) * sx, y: (src.clientY - rect.top) * sy }
560
  }
561
 
562
  function onResize() {
563
- resize()
 
 
 
 
564
  }
565
 
566
  function onMouseDown(e) {
567
- if (!canvas) return
568
  var pt = getCanvasXY(e)
569
  var bsC = worldToCanvas(boatStart.x, boatStart.y)
570
  var beC = worldToCanvas(boatEnd.x, boatEnd.y)
@@ -636,7 +838,7 @@
636
  applyZoom(-e.deltaY)
637
  }
638
 
639
- function bindRange(id, valSpanId) {
640
  var input = $(id)
641
  var span = valSpanId ? $(valSpanId) : null
642
  function sync() {
@@ -645,16 +847,100 @@
645
  input.addEventListener('input', function () {
646
  sync()
647
  resetStreaks()
 
648
  })
649
  sync()
650
  }
651
 
652
  function init() {
653
- canvas = $('c')
654
  wrap = $('canvas-wrap')
655
- if (!canvas || !wrap) return
656
- ctx = canvas.getContext('2d')
657
- if (!ctx) return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
658
 
659
  initTheme()
660
  running = true
@@ -663,14 +949,14 @@
663
  setDir(-1)
664
  window.addEventListener('resize', onResize)
665
 
666
- canvas.addEventListener('mousedown', onMouseDown)
667
- canvas.addEventListener('mousemove', onMouseMove)
668
- canvas.addEventListener('mouseup', onMouseUp)
669
- canvas.addEventListener('touchstart', onTouchStart, { passive: false })
670
- canvas.addEventListener('touchmove', onTouchMove, { passive: false })
671
- canvas.addEventListener('touchend', onTouchEnd)
672
- canvas.addEventListener('touchcancel', onTouchEnd)
673
- canvas.addEventListener('wheel', onWheel, { passive: false })
674
 
675
  $('zoom-out').addEventListener('click', function () {
676
  applyZoom(-1)
@@ -684,13 +970,26 @@
684
  $('dir-ccw').addEventListener('click', function () {
685
  setDir(-1)
686
  })
687
-
688
- bindRange('input-fov', 'val-fov')
 
 
689
  bindRange('input-front-speed', 'val-front-speed')
690
  bindRange('input-back-speed', 'val-back-speed')
691
- bindRange('input-front-arc', 'val-front-arc')
692
  bindRange('input-boat-speed', 'val-boat-speed')
693
 
 
 
 
 
 
 
 
 
 
 
 
694
  rafId = requestAnimationFrame(animate)
695
  }
696
 
 
5
  return document.getElementById(id)
6
  }
7
 
8
+ function svgEl(tag, attrs) {
9
+ var el = document.createElementNS('http://www.w3.org/2000/svg', tag)
10
+ if (attrs) for (var k in attrs) el.setAttribute(k, attrs[k])
11
+ return el
12
+ }
13
+
14
+ var svg = null
15
  var wrap = null
 
16
  var W = 0
17
  var H = 0
18
  var camCX = 0
 
20
  var SCALE = 0
21
  var zoomLevel = 1.0
22
 
23
+ // SVG layer groups (created in init)
24
+ var worldG = null // world coords: camera at (0,0), Y-up, scaled by SCALE
25
+ var bgSky = null
26
+ var bgSea = null
27
+ var horizLine = null
28
+ var ringsG = null // sub-group: range rings + labels (rebuilt on zoom/resize)
29
+ var compassG = null // sub-group: compass ticks (rebuilt on zoom/resize)
30
+ var zonesG = null // sub-group: front/back arc sectors (rebuilt on frontArc change)
31
+
32
+ var dynamicCamG = null // one rotate(camHeading) for FOV wedge + boresight line
33
+ var coneEl = null
34
+ var camPointer = null
35
+ var camDot = null
36
+ var camCorners = []
37
+ var boatG = null
38
+ var boatDot = null
39
+ var boatHalo = null
40
+ var boatLabel = null
41
+ var trajLine = null
42
+ var bearingLine = null
43
+ var anchorS = null
44
+ var anchorE = null
45
+ var anchorSLabel = null
46
+ var anchorELabel = null
47
+ var FONT_SANS = 'Saira Condensed, Arial Narrow, Arial, sans-serif'
48
+ var statCache = {}
49
+
50
+ var lastTrackSx = NaN
51
+ var lastTrackSy = NaN
52
+ var lastTrackEx = NaN
53
+ var lastTrackEy = NaN
54
+ var lastDrawScale = NaN
55
+ var prevInFov = null
56
+
57
  var boatStart = { x: 0.0, y: 1.0 }
58
  var boatEnd = { x: 0.0, y: 0.15 }
59
  var dragging = null
 
73
  var lastPinchDist = null
74
  var rafId = 0
75
  var running = false
76
+ var camDir = 1
77
+
78
+ var inpFov = null
79
+ var inpFrontSpeed = null
80
+ var inpBackSpeed = null
81
+ var inpFrontArc = null
82
+ var inpBoatSpeed = null
83
+
84
+ var cachedConeFov = NaN
85
+ var cachedConeR = NaN
86
+ var cachedPtrLen = NaN
87
+
88
+ var lastDrawBx = NaN
89
+ var lastDrawBy = NaN
90
+
91
+ /** Cached svg.getBoundingClientRect() β€” avoids forced layout on every pointer move (web.dev / performance guides). */
92
+ var hitL = 0
93
+ var hitT = 0
94
+ var hitSx = 1
95
+ var hitSy = 1
96
+
97
+ var resizeRaf = 0
98
+ /** Side panel DOM refresh rate β€” decoupled from rAF so SVG and HTML work split. */
99
+ var panelStatsRaf = 0
100
+ var panelStatsPayload = null
101
+
102
+ var zoomVisualRaf = 0
103
 
104
  // Color palette β€” loaded from CSS custom properties at init
105
  var C = {
 
157
  C.wc = v('--warning-content')
158
  C.hi = v('--content-hi')
159
  C.lo = v('--content-lo')
160
+ // Update SVG elements that depend on theme colors
161
+ if (horizLine) horizLine.setAttribute('stroke', rgba(C.pr, 0.4))
162
+ if (zoomVisualRaf) {
163
+ cancelAnimationFrame(zoomVisualRaf)
164
+ zoomVisualRaf = 0
165
+ }
166
+ buildWorldElements()
167
+ buildZones()
168
+ prevInFov = null
169
+ syncThemedSceneStrokes()
170
+ if (panelStatsRaf) {
171
+ cancelAnimationFrame(panelStatsRaf)
172
+ panelStatsRaf = 0
173
+ }
174
+ panelStatsPayload = null
175
  }
176
 
177
  function rgba(triplet, alpha) {
 
188
  prevVisible = null
189
  }
190
 
191
+ // Clockwise sector path in world coords (Y-up). fromDeg/toDeg are degrees clockwise from north.
192
+ // Uses polyline approximation to avoid SVG arc sweep-flag issues in Y-flipped transforms.
193
+ // maxSteps caps segments for animated paths (FOV cone); omit for high-quality static zones.
194
+ function sectorPath(fromDeg, toDeg, R, maxSteps) {
195
+ var span = ((toDeg - fromDeg) + 360) % 360
196
+ var steps = Math.ceil(span / 2)
197
+ if (maxSteps != null && maxSteps > 0)
198
+ steps = Math.max(4, Math.min(maxSteps, Math.max(steps, 4)))
199
+ else
200
+ steps = Math.max(64, steps)
201
+ var d = 'M 0 0'
202
+ for (var i = 0; i <= steps; i++) {
203
+ var deg = fromDeg + (span / steps) * i
204
+ var rad = deg * Math.PI / 180
205
+ d += ' L ' + (Math.sin(rad) * R) + ' ' + (Math.cos(rad) * R)
206
+ }
207
+ return d + ' Z'
208
+ }
209
+
210
+ /** Furthest viewport corner from camera in world units (tight fill for zones / cone extent). */
211
+ function zoneFillRadiusWorld() {
212
+ if (SCALE === 0) return 0.1
213
+ function distWorld(px, py) {
214
+ var wx = (px - camCX) / SCALE
215
+ var wy = (camCY - py) / SCALE
216
+ return Math.hypot(wx, wy)
217
+ }
218
+ var r = Math.max(
219
+ distWorld(0, 0),
220
+ distWorld(W, 0),
221
+ distWorld(W, H),
222
+ distWorld(0, H),
223
+ )
224
+ var margin = r * 1.08
225
+ return Math.max(margin, 0.04)
226
+ }
227
+
228
+ /** FOV wedge must reach at least past the visible map (avoid huge fixed mins at high zoom). */
229
+ function coneRadiusWorld() {
230
+ if (SCALE === 0) return 0.1
231
+ var byDiag = Math.hypot(W, H) / SCALE * 1.12
232
+ return Math.max(zoneFillRadiusWorld(), byDiag)
233
+ }
234
+
235
+ /** Wedge + pointer geometry (north-centered). Call when FOV, zoom, or resize changes R. */
236
+ function syncConeAndPointerShape(p) {
237
+ if (!coneEl || SCALE === 0) return
238
+ var fov = p ? p.fov : (inpFov ? +inpFov.value : 18)
239
+ var R = coneRadiusWorld()
240
+ var ptrLen = 32 / SCALE
241
+ if (fov === cachedConeFov && Math.abs(R - cachedConeR) < 1e-6 && Math.abs(ptrLen - cachedPtrLen) < 1e-12)
242
+ return
243
+ cachedConeFov = fov
244
+ cachedConeR = R
245
+ cachedPtrLen = ptrLen
246
+ var half = fov / 2
247
+ coneEl.setAttribute('d', sectorPath(-half, half, R, 22))
248
+ camPointer.setAttribute('x2', 0)
249
+ camPointer.setAttribute('y2', ptrLen)
250
+ }
251
+
252
+ function syncThemedSceneStrokes() {
253
+ if (!coneEl) return
254
+ coneEl.setAttribute('fill', rgba(C.pr, 0.12))
255
+ coneEl.setAttribute('stroke', rgba(C.pr, 0.6))
256
+ camPointer.setAttribute('stroke', rgba(C.pr, 0.9))
257
+ camDot.setAttribute('fill', C.p)
258
+ camDot.setAttribute('stroke', rgba(C.pr, 0.5))
259
+ trajLine.setAttribute('stroke', rgba(C.dr, 0.25))
260
+ bearingLine.setAttribute('stroke', rgba(C.dr, 0.12))
261
+ var cornStroke = rgba(C.pr, 0.5)
262
+ for (var i = 0; i < 4; i++) camCorners[i].setAttribute('stroke', cornStroke)
263
+ anchorS.setAttribute('fill', C.d)
264
+ anchorE.setAttribute('fill', C.w)
265
+ anchorSLabel.setAttribute('fill', C.dc)
266
+ anchorELabel.setAttribute('fill', C.wc)
267
+ }
268
+
269
+ function updateTrackSvgGeomIfNeeded() {
270
+ if (boatStart.x === lastTrackSx && boatStart.y === lastTrackSy &&
271
+ boatEnd.x === lastTrackEx && boatEnd.y === lastTrackEy)
272
+ return
273
+ lastTrackSx = boatStart.x
274
+ lastTrackSy = boatStart.y
275
+ lastTrackEx = boatEnd.x
276
+ lastTrackEy = boatEnd.y
277
+
278
+ trajLine.setAttribute('x1', boatStart.x)
279
+ trajLine.setAttribute('y1', boatStart.y)
280
+ trajLine.setAttribute('x2', boatEnd.x)
281
+ trajLine.setAttribute('y2', boatEnd.y)
282
+
283
+ anchorS.setAttribute('cx', boatStart.x)
284
+ anchorS.setAttribute('cy', boatStart.y)
285
+ anchorE.setAttribute('cx', boatEnd.x)
286
+ anchorE.setAttribute('cy', boatEnd.y)
287
+
288
+ var syOff = 14 / SCALE
289
+ anchorSLabel.setAttribute('x', boatStart.x)
290
+ anchorSLabel.setAttribute('y', -(boatStart.y - syOff))
291
+ anchorELabel.setAttribute('x', boatEnd.x)
292
+ anchorELabel.setAttribute('y', -(boatEnd.y - syOff))
293
+ }
294
+
295
+ function updateScaleOnlySvgGeom() {
296
+ var cr = 8 / SCALE
297
+ camDot.setAttribute('r', cr)
298
+
299
+ var cornerPx = 14 / SCALE
300
+ var corners = [
301
+ [-cornerPx, cornerPx],
302
+ [cornerPx, cornerPx],
303
+ [cornerPx, -cornerPx],
304
+ [-cornerPx, -cornerPx],
305
+ ]
306
+ for (var ci = 0; ci < 4; ci++) {
307
+ var wx = corners[ci][0]
308
+ var wy = corners[ci][1]
309
+ camCorners[ci].setAttribute('x1', wx * 0.4)
310
+ camCorners[ci].setAttribute('y1', wy * 0.4)
311
+ camCorners[ci].setAttribute('x2', wx)
312
+ camCorners[ci].setAttribute('y2', wy)
313
+ }
314
+
315
+ var rAnch = 4 / SCALE
316
+ anchorS.setAttribute('r', rAnch)
317
+ anchorE.setAttribute('r', rAnch)
318
+ boatDot.setAttribute('r', 5 / SCALE)
319
+ boatHalo.setAttribute('r', 14 / SCALE)
320
+
321
+ var fsA = 11 / SCALE
322
+ anchorSLabel.setAttribute('font-size', fsA)
323
+ anchorELabel.setAttribute('font-size', fsA)
324
+ boatLabel.setAttribute('font-size', fsA)
325
+ boatLabel.setAttribute('x', 8 / SCALE)
326
+ boatLabel.setAttribute('y', -(4 / SCALE))
327
+ syncConeAndPointerShape(null)
328
+
329
+ invalidateTrackGeomLayout()
330
+ }
331
+
332
+ function invalidateTrackGeomLayout() {
333
+ lastTrackSx = NaN
334
+ }
335
+
336
+ function buildZones() {
337
+ if (!zonesG || SCALE === 0) return
338
+ while (zonesG.firstChild) zonesG.removeChild(zonesG.firstChild)
339
+ var frontArc = inpFrontArc ? +inpFrontArc.value : +$('input-front-arc').value
340
+ var frontHalf = frontArc / 2
341
+ var R = zoneFillRadiusWorld()
342
+
343
+ // Front sector: -frontHalfΒ° to +frontHalfΒ° (through north)
344
+ var front = svgEl('path', { d: sectorPath(-frontHalf, frontHalf, R),
345
+ fill: rgba(C.pr, 0.07), stroke: 'none' })
346
+ zonesG.appendChild(front)
347
+
348
+ // Back sector: +frontHalfΒ° to 360-frontHalfΒ° (through south)
349
+ var back = svgEl('path', { d: sectorPath(frontHalf, 360 - frontHalf, R),
350
+ fill: rgba(C.dr, 0.06), stroke: 'none' })
351
+ zonesG.appendChild(back)
352
+
353
+ // Boundary lines (dashed) at each edge
354
+ var e1r = (-frontHalf) * Math.PI / 180
355
+ var e2r = frontHalf * Math.PI / 180
356
+ zonesG.appendChild(svgEl('line', {
357
+ x1: 0, y1: 0, x2: Math.sin(e1r) * R, y2: Math.cos(e1r) * R,
358
+ stroke: rgba(C.pr, 0.5), 'stroke-width': 1, 'stroke-dasharray': '5 5',
359
+ 'vector-effect': 'non-scaling-stroke'
360
+ }))
361
+ zonesG.appendChild(svgEl('line', {
362
+ x1: 0, y1: 0, x2: Math.sin(e2r) * R, y2: Math.cos(e2r) * R,
363
+ stroke: rgba(C.pr, 0.5), 'stroke-width': 1, 'stroke-dasharray': '5 5',
364
+ 'vector-effect': 'non-scaling-stroke'
365
+ }))
366
+
367
+ // Zone labels in world coords (upright text pattern: y=-wy, transform scale(1,-1))
368
+ var labelR = 80 / SCALE
369
+ var fs = 11 / SCALE
370
+ var font = 'Saira Condensed, Arial Narrow, Arial, sans-serif'
371
+
372
+ var tFront = svgEl('text', { x: 0, y: -labelR, transform: 'scale(1,-1)',
373
+ fill: C.pc, 'font-size': fs, 'font-family': font, 'text-anchor': 'middle' })
374
+ tFront.textContent = 'FRONT ' + frontArc + '\u00b0'
375
+ zonesG.appendChild(tFront)
376
+
377
+ var tBack = svgEl('text', { x: 0, y: 18 / SCALE, transform: 'scale(1,-1)',
378
+ fill: C.dc, 'font-size': fs, 'font-family': font, 'text-anchor': 'middle' })
379
+ tBack.textContent = 'BACK ' + (360 - frontArc) + '\u00b0'
380
+ zonesG.appendChild(tBack)
381
+
382
+ var tE1 = svgEl('text', {
383
+ x: Math.sin(e1r) * labelR, y: -(Math.cos(e1r) * labelR),
384
+ transform: 'scale(1,-1)', fill: C.lo,
385
+ 'font-size': fs, 'font-family': font, 'text-anchor': 'middle'
386
+ })
387
+ tE1.textContent = '\u00b1' + frontHalf.toFixed(0) + '\u00b0'
388
+ zonesG.appendChild(tE1)
389
+
390
+ var tE2 = svgEl('text', {
391
+ x: Math.sin(e2r) * labelR, y: -(Math.cos(e2r) * labelR),
392
+ transform: 'scale(1,-1)', fill: C.lo,
393
+ 'font-size': fs, 'font-family': font, 'text-anchor': 'middle'
394
+ })
395
+ tE2.textContent = '\u00b1' + frontHalf.toFixed(0) + '\u00b0'
396
+ zonesG.appendChild(tE2)
397
+ }
398
+
399
+ function fmtDist(d) {
400
+ return d < 1 ? Math.round(d * 1000) + 'm' : d.toFixed(d < 10 ? 2 : 1).replace(/\.0+$/, '') + 'km'
401
+ }
402
+
403
+ // Rebuild range rings and compass ticks whenever SCALE changes (zoom or resize).
404
+ // All lengths expressed as px/SCALE so they render at a fixed pixel size.
405
+ function buildWorldElements() {
406
+ if (!ringsG || !compassG || SCALE === 0) return
407
+
408
+ // -- Range rings --
409
+ while (ringsG.firstChild) ringsG.removeChild(ringsG.firstChild)
410
+
411
+ var niceIntervals = [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0]
412
+ var ringInterval = niceIntervals[0]
413
+ for (var i = 0; i < niceIntervals.length; i++) {
414
+ var iv = niceIntervals[i]
415
+ var px = iv * SCALE
416
+ if (px >= 100 && px <= 240) { ringInterval = iv; break }
417
+ if (px < 100) ringInterval = iv
418
+ }
419
+ var maxDist = Math.sqrt(Math.pow(Math.max(camCX, W - camCX), 2) + Math.pow(camCY, 2)) / SCALE
420
+ var maxRingCount = Math.ceil(maxDist / ringInterval) + 1
421
+ var fs = 12 / SCALE // font-size in world units β†’ 12px on screen
422
+
423
+ for (var ri = 1; ri <= maxRingCount; ri++) {
424
+ var r = ri * ringInterval
425
+ ringsG.appendChild(svgEl('circle', { cx: 0, cy: 0, r: r, fill: 'none',
426
+ stroke: rgba(C.pr, 0.25), 'stroke-width': 0.5, 'vector-effect': 'non-scaling-stroke' }))
427
+ // Tick at top of ring (world y = r)
428
+ var tw = 5 / SCALE
429
+ ringsG.appendChild(svgEl('line', { x1: -tw, y1: r, x2: tw, y2: r,
430
+ stroke: rgba(C.pr, 0.7), 'stroke-width': 1, 'vector-effect': 'non-scaling-stroke' }))
431
+ // Label: x offset = 8/SCALE, y = r (world). Text uses y=-r + scale(1,-1) to render upright.
432
+ var lbl = svgEl('text', { x: 8 / SCALE, y: -r, transform: 'scale(1,-1)',
433
+ fill: C.pc, 'font-size': fs, 'font-family': 'Saira Condensed, Arial Narrow, Arial, sans-serif' })
434
+ lbl.textContent = fmtDist(ri * ringInterval)
435
+ ringsG.appendChild(lbl)
436
+ }
437
+
438
+ // -- Compass ticks --
439
+ while (compassG.firstChild) compassG.removeChild(compassG.firstChild)
440
+
441
+ var compassR = Math.min(camCX * 0.88, camCY * 0.92) / SCALE
442
+ var fsC = 11 / SCALE
443
+
444
+ // North reference line (dashed, from camera to top)
445
+ compassG.appendChild(svgEl('line', { x1: 0, y1: 0, x2: 0, y2: compassR + 20 / SCALE,
446
+ stroke: rgba(C.pr, 0.2), 'stroke-width': 0.5, 'stroke-dasharray': (4 / SCALE) + ' ' + (4 / SCALE),
447
+ 'vector-effect': 'non-scaling-stroke' }))
448
+
449
+ for (var deg = 0; deg < 360; deg += 10) {
450
+ var isMajor = deg % 30 === 0
451
+ var rad = deg * Math.PI / 180
452
+ var wx0 = Math.sin(rad) * compassR
453
+ var wy0 = Math.cos(rad) * compassR
454
+ var tickLen = (isMajor ? 8 : 4) / SCALE
455
+ var wx1 = Math.sin(rad) * (compassR + tickLen)
456
+ var wy1 = Math.cos(rad) * (compassR + tickLen)
457
+ compassG.appendChild(svgEl('line', {
458
+ x1: wx0, y1: wy0, x2: wx1, y2: wy1,
459
+ stroke: rgba(C.pr, isMajor ? 0.65 : 0.35),
460
+ 'stroke-width': isMajor ? 1 : 0.5,
461
+ 'vector-effect': 'non-scaling-stroke'
462
+ }))
463
+ if (isMajor) {
464
+ var labelR = compassR + (8 + 10) / SCALE
465
+ var lx = Math.sin(rad) * labelR
466
+ var ly = Math.cos(rad) * labelR
467
+ var t = svgEl('text', { x: lx, y: -ly, transform: 'scale(1,-1)',
468
+ fill: C.lo, 'font-size': fsC, 'font-family': 'Saira Condensed, Arial Narrow, Arial, sans-serif',
469
+ 'text-anchor': 'middle', 'dominant-baseline': 'middle' })
470
+ t.textContent = deg + '\u00b0'
471
+ compassG.appendChild(t)
472
+ }
473
+ }
474
+ }
475
+
476
+ function updateWorldTransform() {
477
+ if (!worldG) return
478
+ worldG.setAttribute('transform',
479
+ 'translate(' + camCX + ',' + camCY + ') scale(' + SCALE + ',' + (-SCALE) + ')')
480
+ }
481
+
482
+ function refreshSvgHitBox() {
483
+ if (!svg || W <= 0 || H <= 0) return
484
+ var r = svg.getBoundingClientRect()
485
+ var rw = r.width
486
+ var rh = r.height
487
+ if (rw < 1 || rh < 1) return
488
+ hitL = r.left
489
+ hitT = r.top
490
+ hitSx = W / rw
491
+ hitSy = H / rh
492
+ }
493
+
494
  function resize() {
495
+ if (!svg || !wrap) return
496
  W = wrap.clientWidth
497
  H = wrap.clientHeight
498
+ svg.setAttribute('width', W)
499
+ svg.setAttribute('height', H)
500
  camCX = W / 2
501
  camCY = H * 0.88
502
  SCALE = Math.min(W, H) * 0.42 * zoomLevel
503
+ updateWorldTransform()
504
+ if (bgSky) { bgSky.setAttribute('width', W); bgSky.setAttribute('height', camCY) }
505
+ if (bgSea) { bgSea.setAttribute('y', camCY); bgSea.setAttribute('width', W); bgSea.setAttribute('height', H - camCY) }
506
+ if (horizLine) { horizLine.setAttribute('x2', W); horizLine.setAttribute('y1', camCY); horizLine.setAttribute('y2', camCY) }
507
+ buildWorldElements()
508
+ buildZones()
509
+ if (coneEl && SCALE > 0) {
510
+ updateScaleOnlySvgGeom()
511
+ lastDrawScale = SCALE
512
+ }
513
+ refreshSvgHitBox()
514
  }
515
 
516
  function updateZoomLabel() {
 
518
  if (el) el.textContent = Math.round(zoomLevel * 100) + '%'
519
  }
520
 
521
+ function flushZoomVisuals() {
522
+ zoomVisualRaf = 0
523
+ if (!svg || SCALE <= 0) return
524
+ buildWorldElements()
525
+ buildZones()
526
+ if (coneEl) {
527
+ updateScaleOnlySvgGeom()
528
+ lastDrawScale = SCALE
529
+ }
530
+ refreshSvgHitBox()
531
+ }
532
+
533
+ function scheduleZoomVisuals() {
534
+ if (zoomVisualRaf) return
535
+ zoomVisualRaf = requestAnimationFrame(flushZoomVisuals)
536
+ }
537
+
538
  function applyZoom(delta) {
539
  var factor = delta > 0 ? 1.12 : 1 / 1.12
540
  zoomLevel = Math.max(0.3, Math.min(8, zoomLevel * factor))
541
  SCALE = Math.min(W, H) * 0.42 * zoomLevel
542
+ updateWorldTransform()
543
+ scheduleZoomVisuals()
544
  updateZoomLabel()
545
  }
546
 
547
  function getParams() {
548
  return {
549
+ fov: +inpFov.value,
550
+ frontSpeed: +inpFrontSpeed.value,
551
+ backSpeed: +inpBackSpeed.value,
552
+ frontArc: +inpFrontArc.value,
553
+ boatSpeed: +inpBoatSpeed.value,
554
  }
555
  }
556
 
 
589
  return Math.abs(rel) <= fovVal / 2 && by > -0.05
590
  }
591
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592
  function draw(p, bx, by, inFov) {
593
+ if (!coneEl || SCALE === 0) return
594
+ if (SCALE !== lastDrawScale) {
595
+ lastDrawScale = SCALE
596
+ updateScaleOnlySvgGeom()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
597
  }
598
+ updateTrackSvgGeomIfNeeded()
599
+ syncConeAndPointerShape(p)
600
 
601
+ dynamicCamG.setAttribute('transform', 'rotate(' + camHeading + ')')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
602
 
603
+ if (bx !== lastDrawBx || by !== lastDrawBy) {
604
+ lastDrawBx = bx
605
+ lastDrawBy = by
606
+ bearingLine.setAttribute('x2', bx)
607
+ bearingLine.setAttribute('y2', by)
608
+ boatG.setAttribute('transform', 'translate(' + bx + ',' + by + ')')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609
  }
610
 
611
+ if (prevInFov !== inFov) {
612
+ prevInFov = inFov
613
+ boatDot.setAttribute('fill', inFov ? C.s : C.d)
614
+ boatDot.setAttribute('stroke', inFov ? rgba(C.sr, 0.5) : rgba(C.dr, 0.4))
615
+ boatLabel.setAttribute('fill', inFov ? C.sc : C.dc)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
616
  }
617
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
618
  if (inFov) {
619
+ boatHalo.setAttribute('display', '')
620
+ boatHalo.setAttribute('fill', rgba(C.sr, 0.12))
621
+ } else {
622
+ boatHalo.setAttribute('display', 'none')
623
  }
624
+ }
625
 
626
+ function schedulePanelStats(p, bx, by, inFov) {
627
+ panelStatsPayload = { p: p, bx: bx, by: by, inFov: inFov }
628
+ if (panelStatsRaf) return
629
+ panelStatsRaf = requestAnimationFrame(flushPanelStats)
630
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
631
 
632
+ function flushPanelStats() {
633
+ panelStatsRaf = 0
634
+ if (!panelStatsPayload) return
635
+ var q = panelStatsPayload
636
+ panelStatsPayload = null
637
+ updateStats(q.p, q.bx, q.by, q.inFov)
638
+ }
639
 
640
+ function updateStats(p, bx, by, inFov) {
641
  var dist = Math.sqrt(bx * bx + by * by)
642
  var h360 = ((camHeading % 360) + 360) % 360
643
  var st = scanTime(p)
644
+ var c = statCache
645
+
646
+ var tScan = st.toFixed(1) + 's'
647
+ if (c.elScan && c.lastScan !== tScan) { c.elScan.textContent = tScan; c.lastScan = tScan }
648
+ var tAng = h360.toFixed(1) + 'Β°'
649
+ if (c.elAngle && c.lastAngle !== tAng) { c.elAngle.textContent = tAng; c.lastAngle = tAng }
650
+ var tDist = (dist * 1000).toFixed(0) + 'm'
651
+ if (c.elDist && c.lastDist !== tDist) { c.elDist.textContent = tDist; c.lastDist = tDist }
652
+
653
+ var travelM = (inFov ? detectedStreak : lastDetectedStreak).toFixed(1) + 'm'
654
+ var blindM = (!inFov ? blindStreak : lastBlindStreak).toFixed(1) + 'm'
655
+ if (c.elTravel) {
656
+ if (c.lastTravel !== travelM) { c.elTravel.textContent = travelM; c.lastTravel = travelM }
657
+ var to = inFov ? '1' : '0.5'
658
+ if (c.lastTravelOp !== to) { c.elTravel.style.opacity = to; c.lastTravelOp = to }
 
 
 
 
659
  }
660
+ if (c.elBlind) {
661
+ if (c.lastBlind !== blindM) { c.elBlind.textContent = blindM; c.lastBlind = blindM }
662
+ var bo = !inFov ? '1' : '0.5'
663
+ if (c.lastBlindOp !== bo) { c.elBlind.style.opacity = bo; c.lastBlindOp = bo }
664
  }
665
+
666
+ var tTrPrev = prevDetectedStreak > 0 ? 'prev: ' + prevDetectedStreak.toFixed(1) + 'm' : 'prev: β€”'
667
+ if (c.elTravelPrev && c.lastTravelPrev !== tTrPrev) { c.elTravelPrev.textContent = tTrPrev; c.lastTravelPrev = tTrPrev }
668
+ var tBlPrev = prevBlindStreak > 0 ? 'prev: ' + prevBlindStreak.toFixed(1) + 'm' : 'prev: β€”'
669
+ if (c.elBlindPrev && c.lastBlindPrev !== tBlPrev) { c.elBlindPrev.textContent = tBlPrev; c.lastBlindPrev = tBlPrev }
670
+
671
  var total = prevDetectedStreak + prevBlindStreak
672
+ if (c.elRisk && c.elRiskBar) {
673
  if (total > 0) {
674
  var risk = (prevBlindStreak / total) * 100
675
+ var riskT = risk.toFixed(0) + '%'
676
+ var col = risk > 66 ? 'var(--danger)' : risk > 33 ? 'var(--warning)' : 'var(--success)'
677
+ var w = risk + '%'
678
+ if (c.lastRiskT !== riskT) { c.elRisk.textContent = riskT; c.lastRiskT = riskT }
679
+ if (c.lastRiskCol !== col) { c.elRisk.style.color = col; c.lastRiskCol = col }
680
+ if (c.lastRiskW !== w) { c.elRiskBar.style.width = w; c.lastRiskW = w }
681
+ if (c.lastRiskBg !== col) { c.elRiskBar.style.background = col; c.lastRiskBg = col }
682
  } else {
683
+ if (c.lastRiskT !== 'β€”') { c.elRisk.textContent = 'β€”'; c.lastRiskT = 'β€”' }
684
+ if (c.lastRiskW !== '0%') { c.elRiskBar.style.width = '0%'; c.lastRiskW = '0%' }
685
  }
686
  }
687
+ var vis = inFov ? 'YES' : 'NO'
688
+ var visCls = 'val ' + (inFov ? 'active' : 'inactive')
689
+ if (c.elVis) {
690
+ if (c.lastVis !== vis) { c.elVis.textContent = vis; c.lastVis = vis }
691
+ if (c.lastVisCls !== visCls) { c.elVis.className = visCls; c.lastVisCls = visCls }
692
  }
693
  }
694
 
695
  function animate(ts) {
696
+ if (!running || !svg) return
697
+
698
  if (lastTime === null) lastTime = ts
699
  var dt = Math.min((ts - lastTime) / 1000, 0.1)
700
  lastTime = ts
 
718
 
719
  var front = isFront(camHeading, p.frontArc)
720
  var speed = front ? p.frontSpeed : p.backSpeed
721
+ camHeading -= camDir * speed * dt
722
  if (camHeading >= 180) camHeading -= 360
723
  if (camHeading < -180) camHeading += 360
724
 
 
742
  else blindStreak += distThisFrame
743
  prevVisible = visible
744
 
745
+ if (!coneEl || SCALE === 0) {
746
+ updateStats(p, bx, by, visible)
747
+ } else {
748
+ draw(p, bx, by, visible)
749
+ schedulePanelStats(p, bx, by, visible)
750
+ }
751
  if (running) rafId = requestAnimationFrame(animate)
752
  }
753
 
754
  function getCanvasXY(e) {
755
+ if (!svg) return { x: 0, y: 0 }
 
 
 
756
  var src = 'touches' in e && e.touches.length ? e.touches[0] : e
757
+ return { x: (src.clientX - hitL) * hitSx, y: (src.clientY - hitT) * hitSy }
758
  }
759
 
760
  function onResize() {
761
+ if (resizeRaf) return
762
+ resizeRaf = requestAnimationFrame(function () {
763
+ resizeRaf = 0
764
+ resize()
765
+ })
766
  }
767
 
768
  function onMouseDown(e) {
769
+ if (!svg) return
770
  var pt = getCanvasXY(e)
771
  var bsC = worldToCanvas(boatStart.x, boatStart.y)
772
  var beC = worldToCanvas(boatEnd.x, boatEnd.y)
 
838
  applyZoom(-e.deltaY)
839
  }
840
 
841
+ function bindRange(id, valSpanId, onInput) {
842
  var input = $(id)
843
  var span = valSpanId ? $(valSpanId) : null
844
  function sync() {
 
847
  input.addEventListener('input', function () {
848
  sync()
849
  resetStreaks()
850
+ if (onInput) onInput()
851
  })
852
  sync()
853
  }
854
 
855
  function init() {
856
+ svg = $('c')
857
  wrap = $('canvas-wrap')
858
+ if (!svg || !wrap) return
859
+
860
+ // Build SVG layer structure
861
+ bgSky = svgEl('rect', { x: 0, y: 0, fill: 'var(--surface-1)' })
862
+ bgSea = svgEl('rect', { x: 0, y: 0, fill: 'var(--surface-2)' })
863
+ horizLine = svgEl('line', { x1: 0, 'stroke-dasharray': '6 4' })
864
+ ringsG = svgEl('g', { id: 'rings' })
865
+ compassG = svgEl('g', { id: 'compass' })
866
+ zonesG = svgEl('g', { id: 'zones' })
867
+ worldG = svgEl('g', { id: 'world' })
868
+ worldG.appendChild(compassG)
869
+ worldG.appendChild(zonesG)
870
+ worldG.appendChild(ringsG)
871
+
872
+ dynamicCamG = svgEl('g', { id: 'dynamic-cam' })
873
+ coneEl = svgEl('path', {
874
+ 'stroke-linejoin': 'round', 'stroke-width': 1, 'vector-effect': 'non-scaling-stroke',
875
+ })
876
+ camPointer = svgEl('line', {
877
+ x1: 0, y1: 0, x2: 0, y2: 0.1,
878
+ 'stroke-width': 1.5, 'vector-effect': 'non-scaling-stroke',
879
+ })
880
+ dynamicCamG.appendChild(coneEl)
881
+ dynamicCamG.appendChild(camPointer)
882
+ trajLine = svgEl('line', {
883
+ 'stroke-width': 1, 'stroke-dasharray': '4 3', 'vector-effect': 'non-scaling-stroke',
884
+ })
885
+ bearingLine = svgEl('line', {
886
+ x1: 0, y1: 0, 'stroke-width': 0.5, 'vector-effect': 'non-scaling-stroke',
887
+ })
888
+ boatG = svgEl('g', { id: 'boat-layer' })
889
+ boatHalo = svgEl('circle', { cx: 0, cy: 0, stroke: 'none' })
890
+ boatDot = svgEl('circle', {
891
+ cx: 0, cy: 0, 'stroke-width': 1.5, 'vector-effect': 'non-scaling-stroke',
892
+ })
893
+ boatLabel = svgEl('text', {
894
+ 'text-anchor': 'left', 'dominant-baseline': 'middle',
895
+ transform: 'scale(1,-1)', 'font-family': FONT_SANS,
896
+ })
897
+ boatLabel.textContent = 'TGT'
898
+ boatG.appendChild(boatHalo)
899
+ boatG.appendChild(boatDot)
900
+ boatG.appendChild(boatLabel)
901
+ anchorS = svgEl('circle', { stroke: 'none' })
902
+ anchorE = svgEl('circle', { stroke: 'none' })
903
+ anchorSLabel = svgEl('text', {
904
+ 'text-anchor': 'middle', 'dominant-baseline': 'middle',
905
+ transform: 'scale(1,-1)', 'font-family': FONT_SANS,
906
+ })
907
+ anchorSLabel.textContent = 'S'
908
+ anchorELabel = svgEl('text', {
909
+ 'text-anchor': 'middle', 'dominant-baseline': 'middle',
910
+ transform: 'scale(1,-1)', 'font-family': FONT_SANS,
911
+ })
912
+ anchorELabel.textContent = 'E'
913
+ camDot = svgEl('circle', {
914
+ cx: 0, cy: 0, 'stroke-width': 1.5, 'vector-effect': 'non-scaling-stroke',
915
+ })
916
+ for (var ci = 0; ci < 4; ci++)
917
+ camCorners.push(svgEl('line', { 'stroke-width': 1, 'vector-effect': 'non-scaling-stroke' }))
918
+
919
+ worldG.appendChild(dynamicCamG)
920
+ worldG.appendChild(trajLine)
921
+ worldG.appendChild(bearingLine)
922
+ worldG.appendChild(boatG)
923
+ worldG.appendChild(anchorS)
924
+ worldG.appendChild(anchorE)
925
+ worldG.appendChild(anchorSLabel)
926
+ worldG.appendChild(anchorELabel)
927
+ worldG.appendChild(camDot)
928
+ for (var cj = 0; cj < 4; cj++) worldG.appendChild(camCorners[cj])
929
+
930
+ svg.appendChild(bgSky)
931
+ svg.appendChild(bgSea)
932
+ svg.appendChild(horizLine)
933
+ svg.appendChild(worldG)
934
+
935
+ inpFov = $('input-fov')
936
+ inpFrontSpeed = $('input-front-speed')
937
+ inpBackSpeed = $('input-back-speed')
938
+ inpFrontArc = $('input-front-arc')
939
+ inpBoatSpeed = $('input-boat-speed')
940
+ cachedConeFov = NaN
941
+ cachedConeR = NaN
942
+ lastDrawBx = NaN
943
+ lastDrawBy = NaN
944
 
945
  initTheme()
946
  running = true
 
949
  setDir(-1)
950
  window.addEventListener('resize', onResize)
951
 
952
+ svg.addEventListener('mousedown', onMouseDown)
953
+ svg.addEventListener('mousemove', onMouseMove)
954
+ svg.addEventListener('mouseup', onMouseUp)
955
+ svg.addEventListener('touchstart', onTouchStart, { passive: false })
956
+ svg.addEventListener('touchmove', onTouchMove, { passive: false })
957
+ svg.addEventListener('touchend', onTouchEnd)
958
+ svg.addEventListener('touchcancel', onTouchEnd)
959
+ svg.addEventListener('wheel', onWheel, { passive: false })
960
 
961
  $('zoom-out').addEventListener('click', function () {
962
  applyZoom(-1)
 
970
  $('dir-ccw').addEventListener('click', function () {
971
  setDir(-1)
972
  })
973
+ bindRange('input-fov', 'val-fov', function () {
974
+ cachedConeFov = NaN
975
+ cachedPtrLen = NaN
976
+ })
977
  bindRange('input-front-speed', 'val-front-speed')
978
  bindRange('input-back-speed', 'val-back-speed')
979
+ bindRange('input-front-arc', 'val-front-arc', buildZones)
980
  bindRange('input-boat-speed', 'val-boat-speed')
981
 
982
+ statCache.elScan = $('st-scan')
983
+ statCache.elAngle = $('st-angle')
984
+ statCache.elDist = $('st-dist')
985
+ statCache.elTravel = $('st-travel')
986
+ statCache.elBlind = $('st-blind')
987
+ statCache.elTravelPrev = $('st-travel-prev')
988
+ statCache.elBlindPrev = $('st-blind-prev')
989
+ statCache.elRisk = $('st-risk')
990
+ statCache.elRiskBar = $('st-risk-bar')
991
+ statCache.elVis = $('st-vis')
992
+
993
  rafId = requestAnimationFrame(animate)
994
  }
995
 
index.html CHANGED
@@ -49,7 +49,7 @@
49
  </div>
50
 
51
  <div id="canvas-wrap">
52
- <canvas id="c" width="800" height="600"></canvas>
53
  <div class="zoom-bar">
54
  <button type="button" class="zoom-btn" id="zoom-out" aria-label="Zoom out">βˆ’</button>
55
  <span id="zoom-label" class="zoom-label">100%</span>
@@ -144,26 +144,6 @@
144
  </div>
145
  </div>
146
 
147
- <div class="section">
148
- <div class="section-title">Legend</div>
149
- <div class="section-body">
150
- <div class="legend">
151
- <div class="leg-item">
152
- <span class="leg-dot leg-dot--cam"></span> Camera &amp; FOV
153
- </div>
154
- <div class="leg-item">
155
- <span class="leg-dot leg-dot--boat"></span> Boat (undetected)
156
- </div>
157
- <div class="leg-item">
158
- <span class="leg-dot leg-dot--det"></span> Boat (detected)
159
- </div>
160
- <div class="leg-item"><span class="leg-line leg-line--rings"></span> Range rings</div>
161
- <div class="leg-item">
162
- <span class="leg-line leg-line--arc"></span> Front arc boundary
163
- </div>
164
- </div>
165
- </div>
166
- </div>
167
  </div>
168
  </div>
169
  </div>
 
49
  </div>
50
 
51
  <div id="canvas-wrap">
52
+ <svg id="c"></svg>
53
  <div class="zoom-bar">
54
  <button type="button" class="zoom-btn" id="zoom-out" aria-label="Zoom out">βˆ’</button>
55
  <span id="zoom-label" class="zoom-label">100%</span>
 
144
  </div>
145
  </div>
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  </div>
148
  </div>
149
  </div>
style.css CHANGED
@@ -182,12 +182,17 @@ body {
182
  position: relative;
183
  background: var(--surface-1);
184
  overflow: hidden;
 
 
 
185
  }
186
 
187
- #canvas-wrap canvas {
188
  display: block;
189
  width: 100%;
190
  height: 100%;
 
 
191
  }
192
 
193
  /* ── Zoom bar ────────────────────────────────────────── */
@@ -241,9 +246,15 @@ body {
241
  border-left: var(--stroke);
242
  padding: var(--sp-s);
243
  overflow-y: auto;
 
 
244
  display: flex;
245
  flex-direction: column;
246
  gap: var(--sp-s);
 
 
 
 
247
  }
248
 
249
  /* ── Sections ────────────────────────────────────────── */
@@ -406,7 +417,7 @@ input[type='range']::-moz-range-thumb {
406
  border: none;
407
  }
408
 
409
- /* Direction toggle */
410
  .dir-toggle {
411
  display: flex;
412
  gap: var(--sp-xs);
@@ -452,47 +463,6 @@ input[type='range']::-moz-range-thumb {
452
  .hint-s { color: var(--danger-content); }
453
  .hint-e { color: var(--warning-content); }
454
 
455
- /* ── Legend ──────────────────────────────────────────── */
456
-
457
- .legend {
458
- display: flex;
459
- flex-direction: column;
460
- gap: var(--sp-s);
461
- }
462
-
463
- .leg-item {
464
- display: flex;
465
- align-items: center;
466
- gap: var(--sp-s);
467
- font-size: 12px;
468
- font-weight: 400;
469
- color: var(--content-mid);
470
- text-transform: uppercase;
471
- }
472
-
473
- .leg-dot {
474
- width: 8px;
475
- height: 8px;
476
- border-radius: 50%;
477
- flex-shrink: 0;
478
- }
479
-
480
- .leg-dot--cam { background: #0A67C2; }
481
- .leg-dot--boat { background: #C20A20; }
482
- .leg-dot--det { background: #14A87A; }
483
-
484
- .leg-line {
485
- width: 16px;
486
- height: 1px;
487
- flex-shrink: 0;
488
- }
489
-
490
- .leg-line--rings { background: rgba(10, 103, 194, 0.5); }
491
-
492
- .leg-line--arc {
493
- border-top: 1px dashed rgba(10, 103, 194, 0.5);
494
- height: 0;
495
- }
496
 
497
  /* ── Responsive ──────────────────────────────────────── */
498
 
 
182
  position: relative;
183
  background: var(--surface-1);
184
  overflow: hidden;
185
+ /* Own stacking context so heavy SVG repaints don’t fight the grid panel (paint isolation). */
186
+ isolation: isolate;
187
+ z-index: 0;
188
  }
189
 
190
+ #canvas-wrap svg {
191
  display: block;
192
  width: 100%;
193
  height: 100%;
194
+ overflow: visible;
195
+ shape-rendering: optimizeSpeed;
196
  }
197
 
198
  /* ── Zoom bar ────────────────────────────────────────── */
 
246
  border-left: var(--stroke);
247
  padding: var(--sp-s);
248
  overflow-y: auto;
249
+ height: 100%;
250
+ min-height: 0;
251
  display: flex;
252
  flex-direction: column;
253
  gap: var(--sp-s);
254
+ position: relative;
255
+ z-index: 1;
256
+ /* layout containment only β€” style containment + busy SVG sibling caused visible flicker >~400% zoom. */
257
+ contain: layout;
258
  }
259
 
260
  /* ── Sections ────────────────────────────────────────── */
 
417
  border: none;
418
  }
419
 
420
+ /* Direction toggle (panel only; no on-map indicator) */
421
  .dir-toggle {
422
  display: flex;
423
  gap: var(--sp-xs);
 
463
  .hint-s { color: var(--danger-content); }
464
  .hint-e { color: var(--warning-content); }
465
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
 
467
  /* ── Responsive ──────────────────────────────────────── */
468