kevinconka commited on
Commit
4bcabcd
·
1 Parent(s): d97b165

Enhance camera control features and UI in app.js, index.html, and style.css

Browse files

- Added support for dual camera configurations with corresponding FOV and offset controls in index.html.
- Implemented new functions in app.js to manage camera settings and synchronization.
- Updated styles in style.css to improve layout and usability of camera controls, including new button designs and responsive adjustments.
- Enhanced pan mode options with visual indicators and improved user feedback for camera settings.

Files changed (3) hide show
  1. app.js +263 -32
  2. index.html +64 -10
  3. style.css +214 -0
app.js CHANGED
@@ -37,8 +37,11 @@
37
  var zonesG = null
38
 
39
  var dynamicCamG = null
 
40
  var coneEl = null
 
41
  var camPointer = null
 
42
  var camDot = null
43
  var camCorners = []
44
  var boatG = null
@@ -99,10 +102,11 @@
99
  var inpBackSpeed = null
100
  var inpFrontArc = null
101
  var inpBoatSpeed = null
 
 
102
 
103
- var cachedConeFov = NaN
104
- var cachedConeR = NaN
105
- var cachedPtrLen = NaN
106
 
107
  var lastDrawBx = NaN
108
  var lastDrawBy = NaN
@@ -228,17 +232,25 @@
228
 
229
  function syncConeAndPointerShape(p) {
230
  if (!coneEl || SCALE === 0) return
231
- var fov = p ? p.fov : +inpFov.value
 
 
 
232
  var R = coneRadiusWorld()
233
  var ptrLen = 32 / SCALE
234
- if (fov === cachedConeFov && Math.abs(R - cachedConeR) < 1e-6 && Math.abs(ptrLen - cachedPtrLen) < 1e-12) return
235
- cachedConeFov = fov
236
- cachedConeR = R
237
- cachedPtrLen = ptrLen
238
- var half = fov / 2
239
- coneEl.setAttribute('d', sectorPath(-half, half, R, 22))
240
  camPointer.setAttribute('x2', 0)
241
  camPointer.setAttribute('y2', ptrLen)
 
 
 
 
 
 
242
  }
243
 
244
  function syncThemedSceneStrokes() {
@@ -246,6 +258,7 @@
246
  coneEl.setAttribute('fill', rgba(C.pr, 0.12))
247
  coneEl.setAttribute('stroke', rgba(C.pr, 0.6))
248
  camPointer.setAttribute('stroke', rgba(C.pr, 0.9))
 
249
  camDot.setAttribute('fill', C.p)
250
  camDot.setAttribute('stroke', rgba(C.pr, 0.5))
251
  trajLine.setAttribute('stroke', rgba(C.dr, 0.25))
@@ -331,8 +344,10 @@
331
  var frontArc = +inpFrontArc.value
332
  var fh = frontArc / 2
333
  var R = zoneFillRadiusWorld()
 
334
  zonesG.appendChild(svgEl('path', { d: sectorPath(-fh, fh, R), fill: rgba(C.pr, 0.07), stroke: 'none' }))
335
- zonesG.appendChild(svgEl('path', { d: sectorPath(fh, 360 - fh, R), fill: rgba(C.dr, 0.06), stroke: 'none' }))
 
336
  var e1r = (-fh) * Math.PI / 180
337
  var e2r = fh * Math.PI / 180
338
  zonesG.appendChild(buildLineZone(0, 0, Math.sin(e1r) * R, Math.cos(e1r) * R))
@@ -345,7 +360,9 @@
345
  zonesG.appendChild(tf)
346
  var tb = svgEl('text', { x: 0, y: 18 * MAP_LABEL_SCALE / SCALE, transform: 'scale(1,-1)',
347
  fill: C.dc, 'font-size': fs, 'font-family': FONT_SANS, 'text-anchor': 'middle' })
348
- tb.textContent = 'BACK ' + (360 - frontArc) + '\u00b0'
 
 
349
  zonesG.appendChild(tb)
350
  var edgeTxt = '\u00b1' + fh.toFixed(0) + '\u00b0'
351
  for (var ei = 0; ei < 2; ei++) {
@@ -493,13 +510,113 @@
493
  updateZoomLabel()
494
  }
495
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
  function getParams() {
497
  return {
498
  fov: +inpFov.value,
 
 
 
499
  frontSpeed: +inpFrontSpeed.value,
500
  backSpeed: +inpBackSpeed.value,
501
  frontArc: +inpFrontArc.value,
502
  boatSpeed: +inpBoatSpeed.value,
 
503
  }
504
  }
505
 
@@ -512,9 +629,7 @@
512
  }
513
 
514
  function isFront(heading, arc) {
515
- var h = ((heading % 360) + 360) % 360
516
- if (h > 180) h -= 360
517
- return Math.abs(h) <= arc / 2
518
  }
519
 
520
  function setDir(d) {
@@ -525,11 +640,17 @@
525
  if (ccw) ccw.classList.toggle('active', d === -1)
526
  }
527
 
528
- function isBoatVisible(bx, by, heading, fovVal) {
 
529
  var boatB = (Math.atan2(bx, by) * 180) / Math.PI
530
- /* Cone uses rotate(heading) inside worldG scale(S,-S); boresight world bearing = -heading in this atan2 convention. */
531
- var rel = (((boatB + heading + 540) % 360) - 180)
532
- return Math.abs(rel) <= fovVal / 2 && by > -0.05
 
 
 
 
 
533
  }
534
 
535
  function draw(p, bx, by, inFov) {
@@ -541,6 +662,16 @@
541
  updateTrackSvgGeomIfNeeded()
542
  syncConeAndPointerShape(p)
543
  dynamicCamG.setAttribute('transform', 'rotate(' + camHeading + ')')
 
 
 
 
 
 
 
 
 
 
544
 
545
  var segX = boatEnd.x - boatStart.x
546
  var segY = boatEnd.y - boatStart.y
@@ -587,7 +718,9 @@
587
  function updateStats(p, bx, by, inFov) {
588
  var c = statCache
589
  var dist = Math.hypot(bx, by)
590
- var st = p.frontArc / p.frontSpeed + (360 - p.frontArc) / p.backSpeed
 
 
591
 
592
  function u(el, k, s) {
593
  if (el && c[k] !== s) { el.textContent = s; c[k] = s }
@@ -649,13 +782,25 @@
649
  if (boatT >= 1) { boatT = 1; boatDir = -1 }
650
  if (boatT <= 0) { boatT = 0; boatDir = 1 }
651
  }
652
- var speed = isFront(camHeading, p.frontArc) ? p.frontSpeed : p.backSpeed
653
- camHeading -= camDir * speed * dt
654
- if (camHeading >= 180) camHeading -= 360
655
- if (camHeading < -180) camHeading += 360
 
 
 
 
 
 
 
 
 
 
 
 
656
  var bx = boatStart.x + (boatEnd.x - boatStart.x) * boatT
657
  var by = boatStart.y + (boatEnd.y - boatStart.y) * boatT
658
- var visible = isBoatVisible(bx, by, camHeading, p.fov)
659
  var distThisFrame = p.boatSpeed * 0.514 * dt
660
  if (coneEl && SCALE > 0 && pathLen > 0.001 && !visible) appendTrail(bx, by)
661
 
@@ -797,11 +942,40 @@
797
  sync()
798
  }
799
 
800
- /** VOS #1 = shipped defaults; proposal #2 uses 50° FOV (slider is degrees, not a zoom %). */
 
 
 
801
  var CAMERA_PRESETS = {
802
- vos1: { fov: 18, frontSpeed: 6, backSpeed: 60, frontArc: 180 },
803
- prop1: { fov: 18, frontSpeed: 7, backSpeed: 120, frontArc: 125 },
804
- prop2: { fov: 50, frontSpeed: 8, backSpeed: 120, frontArc: 125 },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
805
  }
806
 
807
  function clearPresetDropdown() {
@@ -812,15 +986,31 @@
812
  function applyCameraPreset(key) {
813
  var p = CAMERA_PRESETS[key]
814
  if (!p || !inpFov) return
 
815
  inpFov.value = String(p.fov)
 
 
816
  inpFrontSpeed.value = String(p.frontSpeed)
817
  inpBackSpeed.value = String(p.backSpeed)
818
  inpFrontArc.value = String(p.frontArc)
 
 
 
 
 
 
 
819
  $('val-fov').textContent = inpFov.value
 
 
 
 
820
  $('val-front-speed').textContent = inpFrontSpeed.value
821
  $('val-back-speed').textContent = inpBackSpeed.value
822
  $('val-front-arc').textContent = inpFrontArc.value
823
- cachedConeFov = cachedPtrLen = NaN
 
 
824
  resetStreaks()
825
  buildZones()
826
  var sel = $('camera-preset')
@@ -843,6 +1033,12 @@
843
  worldG.appendChild(zonesG)
844
  worldG.appendChild(ringsG)
845
 
 
 
 
 
 
 
846
  dynamicCamG = svgEl('g', { id: 'dynamic-cam' })
847
  coneEl = svgEl('path', { 'stroke-linejoin': 'round', 'stroke-width': 1, 'vector-effect': 'non-scaling-stroke' })
848
  camPointer = svgEl('line', { x1: 0, y1: 0, x2: 0, y2: 0.1, 'stroke-width': 1.5, 'vector-effect': 'non-scaling-stroke' })
@@ -889,6 +1085,7 @@
889
  camDot = svgEl('circle', { cx: 0, cy: 0, 'stroke-width': 1.5, 'vector-effect': 'non-scaling-stroke' })
890
  for (var ci = 0; ci < 4; ci++) camCorners.push(svgEl('line', { 'stroke-width': 1, 'vector-effect': 'non-scaling-stroke' }))
891
 
 
892
  worldG.appendChild(dynamicCamG)
893
  worldG.appendChild(trajLine)
894
  worldG.appendChild(boatTrail)
@@ -913,7 +1110,9 @@
913
  inpBackSpeed = $('input-back-speed')
914
  inpFrontArc = $('input-front-arc')
915
  inpBoatSpeed = $('input-boat-speed')
916
- cachedConeFov = cachedConeR = cachedPtrLen = NaN
 
 
917
  lastDrawBx = lastDrawBy = NaN
918
 
919
  initTheme()
@@ -940,7 +1139,7 @@
940
  $('dir-cw').addEventListener('click', function () { setDir(1) })
941
  $('dir-ccw').addEventListener('click', function () { setDir(-1) })
942
  bindRange('input-fov', 'val-fov', function () {
943
- cachedConeFov = cachedPtrLen = NaN
944
  clearPresetDropdown()
945
  })
946
  bindRange('input-front-speed', 'val-front-speed', clearPresetDropdown)
@@ -948,8 +1147,40 @@
948
  bindRange('input-front-arc', 'val-front-arc', function () {
949
  clearPresetDropdown()
950
  buildZones()
 
951
  })
952
  bindRange('input-boat-speed', 'val-boat-speed')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
953
 
954
  var presetSelect = $('camera-preset')
955
  if (presetSelect) {
 
37
  var zonesG = null
38
 
39
  var dynamicCamG = null
40
+ var rearCamG = null
41
  var coneEl = null
42
+ var coneRearEl = null
43
  var camPointer = null
44
+ var camPointerRear = null
45
  var camDot = null
46
  var camCorners = []
47
  var boatG = null
 
102
  var inpBackSpeed = null
103
  var inpFrontArc = null
104
  var inpBoatSpeed = null
105
+ var inpFov2 = null
106
+ var inpCam2Offset = null
107
 
108
+ /** Invalidated when FOV / camera count / scale changes cone geometry. */
109
+ var cachedConeGeomKey = ''
 
110
 
111
  var lastDrawBx = NaN
112
  var lastDrawBy = NaN
 
232
 
233
  function syncConeAndPointerShape(p) {
234
  if (!coneEl || SCALE === 0) return
235
+ var q = p || getParams()
236
+ var fov1 = q.fov
237
+ var fov2 = q.fov2
238
+ var two = q.twoCam
239
  var R = coneRadiusWorld()
240
  var ptrLen = 32 / SCALE
241
+ var key = [fov1, fov2, two ? 1 : 0, R.toFixed(8), ptrLen.toExponential(10)].join('|')
242
+ if (key === cachedConeGeomKey) return
243
+ cachedConeGeomKey = key
244
+ var half1 = fov1 / 2
245
+ coneEl.setAttribute('d', sectorPath(-half1, half1, R, 22))
 
246
  camPointer.setAttribute('x2', 0)
247
  camPointer.setAttribute('y2', ptrLen)
248
+ if (coneRearEl && camPointerRear) {
249
+ var half2 = fov2 / 2
250
+ coneRearEl.setAttribute('d', sectorPath(-half2, half2, R, 22))
251
+ camPointerRear.setAttribute('x2', 0)
252
+ camPointerRear.setAttribute('y2', ptrLen)
253
+ }
254
  }
255
 
256
  function syncThemedSceneStrokes() {
 
258
  coneEl.setAttribute('fill', rgba(C.pr, 0.12))
259
  coneEl.setAttribute('stroke', rgba(C.pr, 0.6))
260
  camPointer.setAttribute('stroke', rgba(C.pr, 0.9))
261
+ /* Secondary FOV colors updated per frame in draw() for current layout. */
262
  camDot.setAttribute('fill', C.p)
263
  camDot.setAttribute('stroke', rgba(C.pr, 0.5))
264
  trajLine.setAttribute('stroke', rgba(C.dr, 0.25))
 
344
  var frontArc = +inpFrontArc.value
345
  var fh = frontArc / 2
346
  var R = zoneFillRadiusWorld()
347
+ var oscillate = isOscillatePan()
348
  zonesG.appendChild(svgEl('path', { d: sectorPath(-fh, fh, R), fill: rgba(C.pr, 0.07), stroke: 'none' }))
349
+ var backFill = oscillate ? rgba(C.dr, 0.022) : rgba(C.dr, 0.06)
350
+ zonesG.appendChild(svgEl('path', { d: sectorPath(fh, 360 - fh, R), fill: backFill, stroke: 'none' }))
351
  var e1r = (-fh) * Math.PI / 180
352
  var e2r = fh * Math.PI / 180
353
  zonesG.appendChild(buildLineZone(0, 0, Math.sin(e1r) * R, Math.cos(e1r) * R))
 
360
  zonesG.appendChild(tf)
361
  var tb = svgEl('text', { x: 0, y: 18 * MAP_LABEL_SCALE / SCALE, transform: 'scale(1,-1)',
362
  fill: C.dc, 'font-size': fs, 'font-family': FONT_SANS, 'text-anchor': 'middle' })
363
+ tb.textContent = oscillate
364
+ ? ('OUTSIDE SWEEP ' + (360 - frontArc) + '\u00b0')
365
+ : ('BACK ' + (360 - frontArc) + '\u00b0')
366
  zonesG.appendChild(tb)
367
  var edgeTxt = '\u00b1' + fh.toFixed(0) + '\u00b0'
368
  for (var ei = 0; ei < 2; ei++) {
 
510
  updateZoomLabel()
511
  }
512
 
513
+ function isTwoCam() {
514
+ var el = document.querySelector('input[name="camera-count"]:checked')
515
+ return !!(el && el.value === '2')
516
+ }
517
+
518
+ function isOscillatePan() {
519
+ var el = document.querySelector('input[name="pan-mode"]:checked')
520
+ return !!(el && el.value === 'oscillate')
521
+ }
522
+
523
+ function normHeading180(deg) {
524
+ var h = ((deg % 360) + 360) % 360
525
+ if (h > 180) h -= 360
526
+ return h
527
+ }
528
+
529
+ /** In Front arc pan mode with two cameras: headings where both boresights lie in the front sector. */
530
+ function oscillationHeadingBounds(p) {
531
+ var fh = p.frontArc / 2
532
+ if (!p.panOscillate || !p.twoCam) return { lo: -fh, hi: fh }
533
+ var off = p.cam2Offset
534
+ var n = Math.max(120, Math.ceil(4 * fh))
535
+ var runs = []
536
+ var s = -1
537
+ for (var i = 0; i <= n; i++) {
538
+ var t = -fh + (2 * fh) * (i / n)
539
+ var ok = Math.abs(normHeading180(t)) <= fh + 1e-9 && Math.abs(normHeading180(t + off)) <= fh + 1e-9
540
+ if (ok && s < 0) s = i
541
+ if (!ok && s >= 0) {
542
+ runs.push({
543
+ lo: -fh + (2 * fh) * (s / n),
544
+ hi: -fh + (2 * fh) * ((i - 1) / n),
545
+ })
546
+ s = -1
547
+ }
548
+ }
549
+ if (s >= 0) runs.push({ lo: -fh + (2 * fh) * (s / n), hi: fh })
550
+ if (!runs.length) return { lo: -fh, hi: fh }
551
+ var h = normHeading180(camHeading)
552
+ var pick = null
553
+ for (var r = 0; r < runs.length; r++) {
554
+ if (h >= runs[r].lo - 1e-6 && h <= runs[r].hi + 1e-6) { pick = runs[r]; break }
555
+ }
556
+ if (!pick) {
557
+ pick = runs[0]
558
+ for (var r2 = 1; r2 < runs.length; r2++) {
559
+ if (runs[r2].hi - runs[r2].lo > pick.hi - pick.lo) pick = runs[r2]
560
+ }
561
+ }
562
+ return { lo: pick.lo, hi: pick.hi }
563
+ }
564
+
565
+ function clampHeadingToFrontArcIfOscillating() {
566
+ if (!isOscillatePan() || !inpFrontArc) return
567
+ var p = getParams()
568
+ var b = oscillationHeadingBounds(p)
569
+ if (camHeading > b.hi) camHeading = b.hi
570
+ if (camHeading < b.lo) camHeading = b.lo
571
+ }
572
+
573
+ function syncPanModeUi() {
574
+ var labels = document.querySelectorAll('.pan-mode-btn')
575
+ for (var i = 0; i < labels.length; i++) {
576
+ var inp = labels[i].querySelector('input[name="pan-mode"]')
577
+ labels[i].classList.toggle('cam-layout-btn--active', !!(inp && inp.checked))
578
+ }
579
+ var backWrap = $('ctrl-back-speed')
580
+ var backInp = $('input-back-speed')
581
+ var osc = isOscillatePan()
582
+ if (backWrap) backWrap.classList.toggle('ctrl--muted', osc)
583
+ if (backInp) {
584
+ backInp.disabled = osc
585
+ backInp.setAttribute('aria-disabled', osc ? 'true' : 'false')
586
+ }
587
+ buildZones()
588
+ }
589
+
590
+ function syncCameraCountUi() {
591
+ var labels = document.querySelectorAll('.cam-count-btn')
592
+ for (var i = 0; i < labels.length; i++) {
593
+ var inp = labels[i].querySelector('input[name="camera-count"]')
594
+ labels[i].classList.toggle('cam-layout-btn--active', !!(inp && inp.checked))
595
+ }
596
+ var two = isTwoCam()
597
+ var row2 = $('camera-panel-cam2-row')
598
+ if (row2) row2.classList.toggle('camera-panel__row--muted', !two)
599
+ if (inpFov2) {
600
+ inpFov2.disabled = !two
601
+ inpFov2.setAttribute('aria-disabled', two ? 'false' : 'true')
602
+ }
603
+ if (inpCam2Offset) {
604
+ inpCam2Offset.disabled = !two
605
+ inpCam2Offset.setAttribute('aria-disabled', two ? 'false' : 'true')
606
+ }
607
+ }
608
+
609
  function getParams() {
610
  return {
611
  fov: +inpFov.value,
612
+ fov2: inpFov2 ? +inpFov2.value : 18,
613
+ cam2Offset: inpCam2Offset ? +inpCam2Offset.value : 180,
614
+ twoCam: isTwoCam(),
615
  frontSpeed: +inpFrontSpeed.value,
616
  backSpeed: +inpBackSpeed.value,
617
  frontArc: +inpFrontArc.value,
618
  boatSpeed: +inpBoatSpeed.value,
619
+ panOscillate: isOscillatePan(),
620
  }
621
  }
622
 
 
629
  }
630
 
631
  function isFront(heading, arc) {
632
+ return Math.abs(normHeading180(heading)) <= arc / 2
 
 
633
  }
634
 
635
  function setDir(d) {
 
640
  if (ccw) ccw.classList.toggle('active', d === -1)
641
  }
642
 
643
+ function isBoatVisible(bx, by, heading, p) {
644
+ if (by <= -0.05) return false
645
  var boatB = (Math.atan2(bx, by) * 180) / Math.PI
646
+ function inCone(hDeg, halfFovDeg) {
647
+ /* Cone uses rotate(hDeg) inside worldG scale(S,-S); match camera visibility. */
648
+ var rel = (((boatB + hDeg + 540) % 360) - 180)
649
+ return Math.abs(rel) <= halfFovDeg
650
+ }
651
+ if (inCone(heading, p.fov / 2)) return true
652
+ if (p.twoCam && inCone(heading + p.cam2Offset, p.fov2 / 2)) return true
653
+ return false
654
  }
655
 
656
  function draw(p, bx, by, inFov) {
 
662
  updateTrackSvgGeomIfNeeded()
663
  syncConeAndPointerShape(p)
664
  dynamicCamG.setAttribute('transform', 'rotate(' + camHeading + ')')
665
+ if (rearCamG && coneRearEl && camPointerRear) {
666
+ if (!p.twoCam) rearCamG.setAttribute('display', 'none')
667
+ else {
668
+ rearCamG.removeAttribute('display')
669
+ rearCamG.setAttribute('transform', 'rotate(' + (camHeading + p.cam2Offset) + ')')
670
+ coneRearEl.setAttribute('fill', rgba(C.wr, 0.1))
671
+ coneRearEl.setAttribute('stroke', rgba(C.wr, 0.55))
672
+ camPointerRear.setAttribute('stroke', rgba(C.wr, 0.85))
673
+ }
674
+ }
675
 
676
  var segX = boatEnd.x - boatStart.x
677
  var segY = boatEnd.y - boatStart.y
 
718
  function updateStats(p, bx, by, inFov) {
719
  var c = statCache
720
  var dist = Math.hypot(bx, by)
721
+ var st = p.panOscillate
722
+ ? (2 * p.frontArc / p.frontSpeed)
723
+ : (p.frontArc / p.frontSpeed + (360 - p.frontArc) / p.backSpeed)
724
 
725
  function u(el, k, s) {
726
  if (el && c[k] !== s) { el.textContent = s; c[k] = s }
 
782
  if (boatT >= 1) { boatT = 1; boatDir = -1 }
783
  if (boatT <= 0) { boatT = 0; boatDir = 1 }
784
  }
785
+ if (p.panOscillate) {
786
+ var ob = oscillationHeadingBounds(p)
787
+ camHeading -= camDir * p.frontSpeed * dt
788
+ if (camHeading > ob.hi) {
789
+ camHeading = ob.hi
790
+ camDir = 1
791
+ } else if (camHeading < ob.lo) {
792
+ camHeading = ob.lo
793
+ camDir = -1
794
+ }
795
+ } else {
796
+ var speed = isFront(camHeading, p.frontArc) ? p.frontSpeed : p.backSpeed
797
+ camHeading -= camDir * speed * dt
798
+ if (camHeading >= 180) camHeading -= 360
799
+ if (camHeading < -180) camHeading += 360
800
+ }
801
  var bx = boatStart.x + (boatEnd.x - boatStart.x) * boatT
802
  var by = boatStart.y + (boatEnd.y - boatStart.y) * boatT
803
+ var visible = isBoatVisible(bx, by, camHeading, p)
804
  var distThisFrame = p.boatSpeed * 0.514 * dt
805
  if (coneEl && SCALE > 0 && pathLen > 0.001 && !visible) appendTrail(bx, by)
806
 
 
942
  sync()
943
  }
944
 
945
+ /**
946
+ * Presets: twoCam, panMode full360|oscillate, fov / fov2 / cam2Offset, front arc, speeds.
947
+ * Front arc and speeds are degrees; FOVs are degrees (slider scale).
948
+ */
949
  var CAMERA_PRESETS = {
950
+ vos1: {
951
+ twoCam: false,
952
+ fov: 18, fov2: 18, cam2Offset: 180,
953
+ frontArc: 180, panMode: 'full360',
954
+ backSpeed: 60, frontSpeed: 6,
955
+ },
956
+ prop1: {
957
+ twoCam: false,
958
+ fov: 18, fov2: 18, cam2Offset: 180,
959
+ frontArc: 160, panMode: 'full360',
960
+ backSpeed: 120, frontSpeed: 7,
961
+ },
962
+ prop2: {
963
+ twoCam: true,
964
+ fov: 18, fov2: 50, cam2Offset: 0,
965
+ frontArc: 150, panMode: 'full360',
966
+ backSpeed: 120, frontSpeed: 8,
967
+ },
968
+ prop3: {
969
+ twoCam: true,
970
+ fov: 18, fov2: 18, cam2Offset: 90,
971
+ frontArc: 180, panMode: 'oscillate',
972
+ backSpeed: 120, frontSpeed: 7,
973
+ },
974
+ }
975
+
976
+ function setRadioChecked(name, value) {
977
+ var el = document.querySelector('input[name="' + name + '"][value="' + value + '"]')
978
+ if (el) el.checked = true
979
  }
980
 
981
  function clearPresetDropdown() {
 
986
  function applyCameraPreset(key) {
987
  var p = CAMERA_PRESETS[key]
988
  if (!p || !inpFov) return
989
+
990
  inpFov.value = String(p.fov)
991
+ if (inpFov2) inpFov2.value = String(p.fov2)
992
+ if (inpCam2Offset) inpCam2Offset.value = String(p.cam2Offset)
993
  inpFrontSpeed.value = String(p.frontSpeed)
994
  inpBackSpeed.value = String(p.backSpeed)
995
  inpFrontArc.value = String(p.frontArc)
996
+
997
+ setRadioChecked('camera-count', p.twoCam ? '2' : '1')
998
+ setRadioChecked('pan-mode', p.panMode === 'oscillate' ? 'oscillate' : 'full360')
999
+
1000
+ syncCameraCountUi()
1001
+ syncPanModeUi()
1002
+
1003
  $('val-fov').textContent = inpFov.value
1004
+ var vf2 = $('val-fov-2')
1005
+ if (vf2 && inpFov2) vf2.textContent = inpFov2.value
1006
+ var vo = $('val-cam2-offset')
1007
+ if (vo && inpCam2Offset) vo.textContent = inpCam2Offset.value
1008
  $('val-front-speed').textContent = inpFrontSpeed.value
1009
  $('val-back-speed').textContent = inpBackSpeed.value
1010
  $('val-front-arc').textContent = inpFrontArc.value
1011
+
1012
+ cachedConeGeomKey = ''
1013
+ clampHeadingToFrontArcIfOscillating()
1014
  resetStreaks()
1015
  buildZones()
1016
  var sel = $('camera-preset')
 
1033
  worldG.appendChild(zonesG)
1034
  worldG.appendChild(ringsG)
1035
 
1036
+ rearCamG = svgEl('g', { id: 'dynamic-cam-rear', display: 'none' })
1037
+ coneRearEl = svgEl('path', { 'stroke-linejoin': 'round', 'stroke-width': 1, 'vector-effect': 'non-scaling-stroke' })
1038
+ camPointerRear = svgEl('line', { x1: 0, y1: 0, x2: 0, y2: 0.1, 'stroke-width': 1.5, 'vector-effect': 'non-scaling-stroke' })
1039
+ rearCamG.appendChild(coneRearEl)
1040
+ rearCamG.appendChild(camPointerRear)
1041
+
1042
  dynamicCamG = svgEl('g', { id: 'dynamic-cam' })
1043
  coneEl = svgEl('path', { 'stroke-linejoin': 'round', 'stroke-width': 1, 'vector-effect': 'non-scaling-stroke' })
1044
  camPointer = svgEl('line', { x1: 0, y1: 0, x2: 0, y2: 0.1, 'stroke-width': 1.5, 'vector-effect': 'non-scaling-stroke' })
 
1085
  camDot = svgEl('circle', { cx: 0, cy: 0, 'stroke-width': 1.5, 'vector-effect': 'non-scaling-stroke' })
1086
  for (var ci = 0; ci < 4; ci++) camCorners.push(svgEl('line', { 'stroke-width': 1, 'vector-effect': 'non-scaling-stroke' }))
1087
 
1088
+ worldG.appendChild(rearCamG)
1089
  worldG.appendChild(dynamicCamG)
1090
  worldG.appendChild(trajLine)
1091
  worldG.appendChild(boatTrail)
 
1110
  inpBackSpeed = $('input-back-speed')
1111
  inpFrontArc = $('input-front-arc')
1112
  inpBoatSpeed = $('input-boat-speed')
1113
+ inpFov2 = $('input-fov-2')
1114
+ inpCam2Offset = $('input-cam2-offset')
1115
+ cachedConeGeomKey = ''
1116
  lastDrawBx = lastDrawBy = NaN
1117
 
1118
  initTheme()
 
1139
  $('dir-cw').addEventListener('click', function () { setDir(1) })
1140
  $('dir-ccw').addEventListener('click', function () { setDir(-1) })
1141
  bindRange('input-fov', 'val-fov', function () {
1142
+ cachedConeGeomKey = ''
1143
  clearPresetDropdown()
1144
  })
1145
  bindRange('input-front-speed', 'val-front-speed', clearPresetDropdown)
 
1147
  bindRange('input-front-arc', 'val-front-arc', function () {
1148
  clearPresetDropdown()
1149
  buildZones()
1150
+ clampHeadingToFrontArcIfOscillating()
1151
  })
1152
  bindRange('input-boat-speed', 'val-boat-speed')
1153
+ bindRange('input-fov-2', 'val-fov-2', function () {
1154
+ cachedConeGeomKey = ''
1155
+ clearPresetDropdown()
1156
+ })
1157
+ bindRange('input-cam2-offset', 'val-cam2-offset', function () {
1158
+ cachedConeGeomKey = ''
1159
+ clearPresetDropdown()
1160
+ clampHeadingToFrontArcIfOscillating()
1161
+ })
1162
+
1163
+ var countRadios = document.querySelectorAll('input[name="camera-count"]')
1164
+ syncCameraCountUi()
1165
+ for (var lr = 0; lr < countRadios.length; lr++) {
1166
+ countRadios[lr].addEventListener('change', function () {
1167
+ syncCameraCountUi()
1168
+ clampHeadingToFrontArcIfOscillating()
1169
+ resetStreaks()
1170
+ cachedConeGeomKey = ''
1171
+ })
1172
+ }
1173
+
1174
+ var panRadios = document.querySelectorAll('input[name="pan-mode"]')
1175
+ syncPanModeUi()
1176
+ for (var pr = 0; pr < panRadios.length; pr++) {
1177
+ panRadios[pr].addEventListener('change', function () {
1178
+ syncPanModeUi()
1179
+ clampHeadingToFrontArcIfOscillating()
1180
+ resetStreaks()
1181
+ cachedConeGeomKey = ''
1182
+ })
1183
+ }
1184
 
1185
  var presetSelect = $('camera-preset')
1186
  if (presetSelect) {
index.html CHANGED
@@ -66,7 +66,7 @@
66
  <div class="section-title">Status</div>
67
  <div class="stats-grid">
68
  <div class="stat">
69
- <div class="lbl">Scan time</div>
70
  <div id="st-scan" class="val">—</div>
71
  </div>
72
  <div class="stat">
@@ -108,26 +108,80 @@
108
  <label for="camera-preset">Presets</label>
109
  <select id="camera-preset" class="preset-select" aria-label="Apply a camera preset">
110
  <option value="" selected>—</option>
111
- <option value="vos1">VOS #1 (default)</option>
112
  <option value="prop1">Proposal #1</option>
113
  <option value="prop2">Proposal #2</option>
 
114
  </select>
115
  </div>
116
  <div class="ctrl">
117
- <label for="input-fov">FOV <span id="val-fov">18</span>°</label>
118
- <input id="input-fov" type="range" min="5" max="90" step="1" value="18" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  </div>
120
  <div class="ctrl">
121
- <label for="input-front-speed">Front speed <span id="val-front-speed">6</span>°/s</label>
122
- <input id="input-front-speed" type="range" min="1" max="30" step="1" value="6" />
123
  </div>
124
  <div class="ctrl">
125
- <label for="input-back-speed">Back speed <span id="val-back-speed">60</span>°/s</label>
126
- <input id="input-back-speed" type="range" min="5" max="180" step="5" value="60" />
 
 
 
 
 
 
 
 
 
 
127
  </div>
128
  <div class="ctrl">
129
- <label for="input-front-arc">Front arc <span id="val-front-arc">180</span>°</label>
130
- <input id="input-front-arc" type="range" min="10" max="350" step="5" value="180" />
 
 
 
 
131
  </div>
132
  </div>
133
  </div>
 
66
  <div class="section-title">Status</div>
67
  <div class="stats-grid">
68
  <div class="stat">
69
+ <div class="lbl" id="st-scan-lbl" title="Full 360°: time for one rotation at current front/back speeds. Front arc only: time for an out-and-back sweep within the front sector.">Scan time</div>
70
  <div id="st-scan" class="val">—</div>
71
  </div>
72
  <div class="stat">
 
108
  <label for="camera-preset">Presets</label>
109
  <select id="camera-preset" class="preset-select" aria-label="Apply a camera preset">
110
  <option value="" selected>—</option>
111
+ <option value="vos1">VOS #1</option>
112
  <option value="prop1">Proposal #1</option>
113
  <option value="prop2">Proposal #2</option>
114
+ <option value="prop3">Proposal #3</option>
115
  </select>
116
  </div>
117
  <div class="ctrl">
118
+ <label id="camera-count-label" class="cam-layout-label">Rig cameras</label>
119
+ <div class="cam-layout-bar" role="radiogroup" aria-labelledby="camera-count-label">
120
+ <label class="cam-layout-btn cam-count-btn" title="One camera on the panning rig">
121
+ <input type="radio" name="camera-count" value="1" checked />
122
+ <span class="cam-layout-btn__text">1 camera</span>
123
+ </label>
124
+ <label class="cam-layout-btn cam-count-btn" title="Two cameras; same pan, offset boresight">
125
+ <input type="radio" name="camera-count" value="2" />
126
+ <span class="cam-layout-btn__text">2 cameras</span>
127
+ </label>
128
+ </div>
129
+ <p class="toggle-hint">Same rig rotation (front / back arc speeds). Coverage = union of camera FOVs.</p>
130
+ </div>
131
+ <div class="camera-panel" aria-label="Camera fields of view and mounting offset">
132
+ <div class="camera-panel__title">Camera heads</div>
133
+ <div class="camera-panel__row camera-panel__row--ref">
134
+ <span class="camera-panel__badge" title="Reference boresight for pan">1</span>
135
+ <div class="camera-panel__stem">
136
+ <div class="camera-panel__field camera-panel__field--cam1-fov">
137
+ <label for="input-fov">Cam 1 FOV <span id="val-fov">18</span>°</label>
138
+ <input id="input-fov" type="range" min="5" max="90" step="1" value="18" aria-label="Camera 1 field of view in degrees" />
139
+ </div>
140
+ <div class="camera-panel__field camera-panel__field--reference">
141
+ <span class="camera-panel__ref-label">Offset from cam 1</span>
142
+ <span class="camera-panel__ref-val" aria-label="Camera 1 offset, fixed reference">0°</span>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ <div class="camera-panel__row" id="camera-panel-cam2-row">
147
+ <span class="camera-panel__badge" title="Second camera boresight">2</span>
148
+ <div class="camera-panel__stem">
149
+ <div class="camera-panel__field">
150
+ <label for="input-fov-2">Cam 2 FOV <span id="val-fov-2">18</span>°</label>
151
+ <input id="input-fov-2" type="range" min="5" max="90" step="1" value="18" aria-label="Camera 2 field of view in degrees" />
152
+ </div>
153
+ <div class="camera-panel__field">
154
+ <label for="input-cam2-offset">Offset from cam 1 <span id="val-cam2-offset">180</span>°</label>
155
+ <input id="input-cam2-offset" type="range" min="0" max="360" step="1" value="180" aria-label="bearing offset of camera 2 from camera 1 in degrees" />
156
+ </div>
157
+ </div>
158
+ </div>
159
  </div>
160
  <div class="ctrl">
161
+ <label for="input-front-arc">Front arc <span id="val-front-arc">180</span>°</label>
162
+ <input id="input-front-arc" type="range" min="10" max="350" step="5" value="180" />
163
  </div>
164
  <div class="ctrl">
165
+ <label id="pan-mode-label" class="cam-layout-label">Pan mode</label>
166
+ <div class="cam-layout-bar" role="radiogroup" aria-labelledby="pan-mode-label">
167
+ <label class="cam-layout-btn pan-mode-btn" title="Continuous 360° rotation: slower in the front sector, faster in the back sector (back speed).">
168
+ <input type="radio" name="pan-mode" value="full360" checked />
169
+ <span class="cam-layout-btn__text">360° sweep</span>
170
+ </label>
171
+ <label class="cam-layout-btn pan-mode-btn" title="Stay inside the front arc: reverses at the sector edges. Uses front speed only.">
172
+ <input type="radio" name="pan-mode" value="oscillate" />
173
+ <span class="cam-layout-btn__text">Front arc</span>
174
+ </label>
175
+ </div>
176
+ <p class="toggle-hint">CW / CCW flips direction; in Front arc mode the rig reverses at sector limits. With 2 cameras, limits keep both boresights inside the front sector when possible.</p>
177
  </div>
178
  <div class="ctrl">
179
+ <label for="input-front-speed">Front speed <span id="val-front-speed">6</span>°/s</label>
180
+ <input id="input-front-speed" type="range" min="1" max="30" step="1" value="6" />
181
+ </div>
182
+ <div id="ctrl-back-speed" class="ctrl">
183
+ <label for="input-back-speed">Back speed <span id="val-back-speed">60</span>°/s</label>
184
+ <input id="input-back-speed" type="range" min="5" max="180" step="1" value="60" aria-label="Back sector rotation speed, degrees per second" />
185
  </div>
186
  </div>
187
  </div>
style.css CHANGED
@@ -400,6 +400,220 @@ body {
400
  font-weight: 400;
401
  }
402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  /* Range slider */
404
  input[type='range'] {
405
  width: 100%;
 
400
  font-weight: 400;
401
  }
402
 
403
+ .cam-layout-label {
404
+ display: block;
405
+ font-size: 14px;
406
+ font-weight: 500;
407
+ color: var(--content-lo);
408
+ margin-bottom: var(--sp-xs);
409
+ text-transform: uppercase;
410
+ }
411
+
412
+ .cam-layout-bar {
413
+ display: flex;
414
+ gap: 1px;
415
+ background: var(--surface-4);
416
+ border-radius: var(--radious-s);
417
+ padding: 2px;
418
+ }
419
+
420
+ .cam-layout-btn {
421
+ flex: 1 1 0;
422
+ position: relative;
423
+ box-sizing: border-box;
424
+ min-width: 0;
425
+ min-height: 44px;
426
+ padding: var(--sp-xs) 4px;
427
+ margin: 0;
428
+ display: flex;
429
+ align-items: center;
430
+ justify-content: center;
431
+ text-align: center;
432
+ background: transparent;
433
+ border: 1px solid transparent;
434
+ border-radius: 3px;
435
+ cursor: pointer;
436
+ font-family: var(--font);
437
+ font-size: 11px;
438
+ font-weight: 500;
439
+ letter-spacing: 0.02em;
440
+ line-height: 1.15;
441
+ text-transform: uppercase;
442
+ color: var(--content-lo);
443
+ transition: background 0.2s ease-in-out, color 0.2s ease-in-out, border-color 0.2s ease-in-out;
444
+ }
445
+
446
+ .cam-layout-btn:hover {
447
+ color: var(--content-hi);
448
+ }
449
+
450
+ .cam-layout-btn input {
451
+ position: absolute;
452
+ opacity: 0;
453
+ width: 0;
454
+ height: 0;
455
+ margin: 0;
456
+ pointer-events: none;
457
+ }
458
+
459
+ .cam-layout-btn__text {
460
+ pointer-events: none;
461
+ display: block;
462
+ width: 100%;
463
+ text-align: center;
464
+ color: inherit;
465
+ font-size: inherit;
466
+ font-weight: inherit;
467
+ }
468
+
469
+ /* .ctrl label { justify-content: space-between; font-size: 14px } must not apply to segmented bar buttons */
470
+ .ctrl .cam-layout-bar .cam-layout-btn {
471
+ justify-content: center;
472
+ align-items: center;
473
+ margin-bottom: 0;
474
+ font-size: 11px;
475
+ font-weight: 500;
476
+ }
477
+
478
+ .ctrl .cam-layout-bar .cam-layout-btn .cam-layout-btn__text {
479
+ flex: 0 1 auto;
480
+ }
481
+
482
+ .cam-layout-btn--active {
483
+ background: var(--primary-dim);
484
+ border-color: var(--primary);
485
+ color: var(--primary-content);
486
+ }
487
+
488
+ .toggle-hint {
489
+ margin: 0;
490
+ font-size: 12px;
491
+ font-weight: 400;
492
+ line-height: 1.45;
493
+ color: var(--content-lo);
494
+ text-transform: none;
495
+ }
496
+
497
+ .cam-layout-bar + .toggle-hint {
498
+ margin-top: var(--sp-s);
499
+ }
500
+
501
+ /* Camera heads — fixed layout; row 2 dims when 1 camera selected */
502
+ .camera-panel {
503
+ margin-bottom: var(--sp-m);
504
+ padding: var(--sp-m);
505
+ background: var(--surface-3);
506
+ border-radius: var(--radious-m);
507
+ border: var(--stroke);
508
+ }
509
+
510
+ .camera-panel__title {
511
+ font-size: 14px;
512
+ font-weight: 500;
513
+ color: var(--content-lo);
514
+ margin: 0 0 var(--sp-m) 0;
515
+ text-transform: uppercase;
516
+ }
517
+
518
+ .camera-panel__row {
519
+ display: flex;
520
+ gap: var(--sp-s);
521
+ align-items: flex-start;
522
+ margin-bottom: var(--sp-m);
523
+ }
524
+
525
+ .camera-panel__row:last-child {
526
+ margin-bottom: 0;
527
+ }
528
+
529
+ .camera-panel__badge {
530
+ flex-shrink: 0;
531
+ width: 26px;
532
+ height: 26px;
533
+ margin-top: 22px;
534
+ border-radius: 50%;
535
+ display: flex;
536
+ align-items: center;
537
+ justify-content: center;
538
+ font-size: 13px;
539
+ font-weight: 600;
540
+ background: var(--surface-4);
541
+ color: var(--content-mid);
542
+ border: 1px solid rgba(255, 255, 255, 0.08);
543
+ }
544
+
545
+ .coastal-surveillance-sim[data-theme='light'] .camera-panel__badge {
546
+ border-color: rgba(0, 0, 0, 0.1);
547
+ }
548
+
549
+ .camera-panel__stem {
550
+ flex: 1;
551
+ min-width: 0;
552
+ display: flex;
553
+ flex-direction: column;
554
+ gap: var(--sp-s);
555
+ }
556
+
557
+ .camera-panel__field label {
558
+ display: flex;
559
+ justify-content: space-between;
560
+ font-size: 13px;
561
+ font-weight: 500;
562
+ color: var(--content-lo);
563
+ margin-bottom: var(--sp-xs);
564
+ text-transform: uppercase;
565
+ }
566
+
567
+ .camera-panel__row--ref .camera-panel__field--cam1-fov label {
568
+ color: var(--content-mid);
569
+ }
570
+
571
+ .camera-panel__field--reference {
572
+ display: flex;
573
+ align-items: center;
574
+ justify-content: space-between;
575
+ padding: var(--sp-s) var(--sp-m);
576
+ background: var(--surface-4);
577
+ border-radius: var(--radious-s);
578
+ border: 1px solid rgba(255, 255, 255, 0.06);
579
+ }
580
+
581
+ .coastal-surveillance-sim[data-theme='light'] .camera-panel__field--reference {
582
+ border-color: rgba(0, 0, 0, 0.08);
583
+ }
584
+
585
+ .camera-panel__ref-label {
586
+ font-size: 11px;
587
+ font-weight: 500;
588
+ color: var(--content-lo);
589
+ text-transform: uppercase;
590
+ letter-spacing: 0.02em;
591
+ }
592
+
593
+ .camera-panel__ref-val {
594
+ font-size: 15px;
595
+ font-weight: 500;
596
+ color: var(--content-mid);
597
+ }
598
+
599
+ .camera-panel__row--muted {
600
+ opacity: 0.48;
601
+ pointer-events: none;
602
+ }
603
+
604
+ .camera-panel__row--muted label {
605
+ color: var(--content-lo);
606
+ }
607
+
608
+ .ctrl--muted {
609
+ opacity: 0.48;
610
+ pointer-events: none;
611
+ }
612
+
613
+ .ctrl--muted label {
614
+ color: var(--content-lo);
615
+ }
616
+
617
  /* Range slider */
618
  input[type='range'] {
619
  width: 100%;