kevinconka commited on
Commit
0c57009
Β·
1 Parent(s): e5c56d3

Enhance Coastal Surveillance Simulator by adding new features and improving UI

Browse files

- Introduced a new `rgba` function for color management in app.js.
- Added new variables for trail management and anchor elements.
- Updated index.html to reflect a new camera preset selection feature.
- Adjusted styles in style.css for improved layout and responsiveness, including increased button sizes and font adjustments for better readability.
- Enhanced theme toggle functionality with updated button designs and hover effects.

Files changed (3) hide show
  1. app.js +399 -431
  2. index.html +22 -17
  3. style.css +119 -33
app.js CHANGED
@@ -11,6 +11,10 @@
11
  return el
12
  }
13
 
 
 
 
 
14
  var svg = null
15
  var wrap = null
16
  var W = 0
@@ -19,31 +23,44 @@
19
  var camCY = 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
 
@@ -71,9 +88,8 @@
71
  var prevBlindStreak = 0
72
 
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
@@ -88,20 +104,16 @@
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 = {
106
  p: '', d: '', s: '', w: '', s1: '', s2: '',
107
  pr: '', dr: '', sr: '', wr: '',
@@ -110,94 +122,84 @@
110
 
111
  var systemQuery = window.matchMedia('(prefers-color-scheme: light)')
112
 
113
- function applyTheme(pref) {
114
- var sim = document.querySelector('.coastal-surveillance-sim')
115
- var resolved = pref === 'system' ? (systemQuery.matches ? 'light' : 'dark') : pref
116
- sim.dataset.theme = resolved
117
- loadColors()
118
- var btns = document.querySelectorAll('.theme-btn')
119
- for (var i = 0; i < btns.length; i++) {
120
- btns[i].classList.toggle('active', btns[i].dataset.themeVal === pref)
121
- }
122
- }
123
-
124
- function initTheme() {
125
- var saved = localStorage.getItem('theme') || 'system'
126
- applyTheme(saved)
127
- systemQuery.addEventListener('change', function () {
128
- if (localStorage.getItem('theme') === 'system') applyTheme('system')
129
- })
130
- var btns = document.querySelectorAll('.theme-btn')
131
- for (var i = 0; i < btns.length; i++) {
132
- btns[i].addEventListener('click', function () {
133
- var val = this.dataset.themeVal
134
- localStorage.setItem('theme', val)
135
- applyTheme(val)
136
- })
137
- }
138
- }
139
-
140
  function loadColors() {
141
- var el = document.querySelector('.coastal-surveillance-sim')
142
- var cs = getComputedStyle(el)
143
  function v(n) { return cs.getPropertyValue(n).trim() }
144
- C.p = v('--primary')
145
- C.d = v('--danger')
146
- C.s = v('--success')
147
- C.w = v('--warning')
148
- C.s1 = v('--surface-1')
149
- C.s2 = v('--surface-2')
150
- C.pr = v('--primary-rgb')
151
- C.dr = v('--danger-rgb')
152
- C.sr = v('--success-rgb')
153
- C.wr = v('--warning-rgb')
154
- C.pc = v('--primary-content')
155
- C.dc = v('--danger-content')
156
- C.sc = v('--success-content')
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) {
178
- return 'rgba(' + triplet + ',' + alpha + ')'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  }
180
 
181
  function resetStreaks() {
182
- detectedStreak = 0
183
- blindStreak = 0
184
- lastDetectedStreak = 0
185
- lastBlindStreak = 0
186
- prevDetectedStreak = 0
187
- prevBlindStreak = 0
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
@@ -207,39 +209,25 @@
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
@@ -258,34 +246,42 @@
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)
@@ -294,179 +290,127 @@
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
  }
@@ -474,21 +418,28 @@
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() {
@@ -498,19 +449,13 @@
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() {
@@ -521,18 +466,11 @@
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) {
@@ -568,10 +506,6 @@
568
  return Math.abs(h) <= arc / 2
569
  }
570
 
571
- function scanTime(p) {
572
- return p.frontArc / p.frontSpeed + (360 - p.frontArc) / p.backSpeed
573
- }
574
-
575
  function setDir(d) {
576
  camDir = d
577
  var cw = $('dir-cw')
@@ -580,12 +514,10 @@
580
  if (ccw) ccw.classList.toggle('active', d === -1)
581
  }
582
 
583
- function boatAngle(bx, by) {
584
- return (Math.atan2(bx, by) * 180) / Math.PI
585
- }
586
-
587
  function isBoatVisible(bx, by, heading, fovVal) {
588
- var rel = (((boatAngle(bx, by) - heading + 540) % 360) - 180)
 
 
589
  return Math.abs(rel) <= fovVal / 2 && by > -0.05
590
  }
591
 
@@ -597,9 +529,21 @@
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
@@ -607,128 +551,104 @@
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
701
-
702
  var p = getParams()
703
-
704
  var boatKmS = p.boatSpeed * 0.000514
705
  var pathLen = Math.hypot(boatEnd.x - boatStart.x, boatEnd.y - boatStart.y)
706
  if (pathLen > 0.001) {
707
- var dtT = (boatKmS * dt) / pathLen
708
- boatT += boatDir * dtT
709
- if (boatT >= 1) {
710
- boatT = 1
711
- boatDir = -1
712
- }
713
- if (boatT <= 0) {
714
- boatT = 0
715
- boatDir = 1
716
- }
717
  }
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
-
725
  var bx = boatStart.x + (boatEnd.x - boatStart.x) * boatT
726
  var by = boatStart.y + (boatEnd.y - boatStart.y) * boatT
727
  var visible = isBoatVisible(bx, by, camHeading, p.fov)
728
-
729
  var distThisFrame = p.boatSpeed * 0.514 * dt
730
  if (prevVisible !== null && visible !== prevVisible) {
731
  if (visible) {
 
732
  prevBlindStreak = blindStreak
733
  lastBlindStreak = blindStreak
734
  blindStreak = 0
@@ -742,68 +662,91 @@
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)
773
- if (Math.hypot(pt.x - bsC.x, pt.y - bsC.y) < 14) dragging = 'start'
774
- else if (Math.hypot(pt.x - beC.x, pt.y - beC.y) < 14) dragging = 'end'
775
  }
776
 
777
- function onMouseMove(e) {
778
- if (!dragging) return
779
  var pt = getCanvasXY(e)
780
  var w = canvasToWorld(pt.x, pt.y)
781
  w.y = Math.max(0.05, Math.min(1.3, w.y))
782
  w.x = Math.max(-1.4, Math.min(1.4, w.x))
783
  if (dragging === 'start') boatStart = w
784
- else boatEnd = w
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
785
  }
786
 
787
  function onMouseUp() {
788
  dragging = null
 
 
 
 
789
  }
790
 
791
  function onTouchStart(e) {
792
  if (e.touches.length === 2) {
793
  lastPinchDist = Math.hypot(
794
  e.touches[0].clientX - e.touches[1].clientX,
795
- e.touches[0].clientY - e.touches[1].clientY,
796
- )
797
  dragging = null
798
  return
799
  }
800
  if (e.touches.length !== 1) return
801
  e.preventDefault()
802
- var pt = getCanvasXY(e)
803
- var bsC = worldToCanvas(boatStart.x, boatStart.y)
804
- var beC = worldToCanvas(boatEnd.x, boatEnd.y)
805
- if (Math.hypot(pt.x - bsC.x, pt.y - bsC.y) < 18) dragging = 'start'
806
- else if (Math.hypot(pt.x - beC.x, pt.y - beC.y) < 18) dragging = 'end'
807
  }
808
 
809
  function onTouchMove(e) {
@@ -811,21 +754,14 @@
811
  e.preventDefault()
812
  var dist = Math.hypot(
813
  e.touches[0].clientX - e.touches[1].clientX,
814
- e.touches[0].clientY - e.touches[1].clientY,
815
- )
816
- var delta = dist - lastPinchDist
817
- applyZoom(delta)
818
  lastPinchDist = dist
819
  return
820
  }
821
  if (!dragging) return
822
  e.preventDefault()
823
- var pt = getCanvasXY(e)
824
- var w = canvasToWorld(pt.x, pt.y)
825
- w.y = Math.max(0.05, Math.min(1.3, w.y))
826
- w.x = Math.max(-1.4, Math.min(1.4, w.x))
827
- if (dragging === 'start') boatStart = w
828
- else boatEnd = w
829
  }
830
 
831
  function onTouchEnd(e) {
@@ -841,9 +777,7 @@
841
  function bindRange(id, valSpanId, onInput) {
842
  var input = $(id)
843
  var span = valSpanId ? $(valSpanId) : null
844
- function sync() {
845
- if (span) span.textContent = input.value
846
- }
847
  input.addEventListener('input', function () {
848
  sync()
849
  resetStreaks()
@@ -852,12 +786,41 @@
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' })
@@ -870,57 +833,59 @@
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)
@@ -937,10 +902,8 @@
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
@@ -952,35 +915,40 @@
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)
963
- })
964
- $('zoom-in').addEventListener('click', function () {
965
- applyZoom(1)
966
- })
967
- $('dir-cw').addEventListener('click', function () {
968
- setDir(1)
969
- })
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')
@@ -990,7 +958,7 @@
990
  statCache.elRiskBar = $('st-risk-bar')
991
  statCache.elVis = $('st-vis')
992
 
993
- rafId = requestAnimationFrame(animate)
994
  }
995
 
996
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init)
 
11
  return el
12
  }
13
 
14
+ function rgba(triplet, alpha) {
15
+ return 'rgba(' + triplet + ',' + alpha + ')'
16
+ }
17
+
18
  var svg = null
19
  var wrap = null
20
  var W = 0
 
23
  var camCY = 0
24
  var SCALE = 0
25
  var zoomLevel = 1.0
26
+ /** Keeps map labels readable vs panel type (~15% bump; pair with .coastal-surveillance-sim font-size). */
27
+ var MAP_LABEL_SCALE = 1.15
28
 
29
+ var worldG = null
 
30
  var bgSky = null
31
  var bgSea = null
32
  var horizLine = null
33
+ var ringsG = null
34
+ var compassG = null
35
+ var zonesG = null
36
 
37
+ var dynamicCamG = null
38
  var coneEl = null
39
  var camPointer = null
40
  var camDot = null
41
  var camCorners = []
42
  var boatG = null
 
43
  var boatHalo = null
44
+ var boatIconG = null
45
+ var boatIconScaleG = null
46
+ var boatHull = null
47
  var trajLine = null
48
+ var boatTrail = null
49
  var bearingLine = null
50
+ var anchorSOuter = null
51
  var anchorS = null
52
+ var anchorEOuter = null
53
  var anchorE = null
54
  var anchorSLabel = null
55
  var anchorELabel = null
56
+
57
+ var trailFlat = []
58
+ var TRAIL_CAP = 100
59
+ var TRAIL_MIN_DIST = 0.0028
60
+ /** Min blind+detected metres before showing blind share (avoids noisy % on tiny streaks). */
61
+ var MIN_BLIND_SHARE_M = 10
62
+ var lastTrailIconAngle = NaN
63
+
64
  var FONT_SANS = 'Saira Condensed, Arial Narrow, Arial, sans-serif'
65
  var statCache = {}
66
 
 
88
  var prevBlindStreak = 0
89
 
90
  var lastPinchDist = null
 
91
  var running = false
92
+ var camDir = -1
93
 
94
  var inpFov = null
95
  var inpFrontSpeed = null
 
104
  var lastDrawBx = NaN
105
  var lastDrawBy = NaN
106
 
 
107
  var hitL = 0
108
  var hitT = 0
109
  var hitSx = 1
110
  var hitSy = 1
111
 
112
  var resizeRaf = 0
 
113
  var panelStatsRaf = 0
114
  var panelStatsPayload = null
 
115
  var zoomVisualRaf = 0
116
 
 
117
  var C = {
118
  p: '', d: '', s: '', w: '', s1: '', s2: '',
119
  pr: '', dr: '', sr: '', wr: '',
 
122
 
123
  var systemQuery = window.matchMedia('(prefers-color-scheme: light)')
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  function loadColors() {
126
+ var cs = getComputedStyle(document.querySelector('.coastal-surveillance-sim'))
 
127
  function v(n) { return cs.getPropertyValue(n).trim() }
128
+ C.p = v('--primary'); C.d = v('--danger'); C.s = v('--success'); C.w = v('--warning')
129
+ C.s1 = v('--surface-1'); C.s2 = v('--surface-2')
130
+ C.pr = v('--primary-rgb'); C.dr = v('--danger-rgb'); C.sr = v('--success-rgb'); C.wr = v('--warning-rgb')
131
+ C.pc = v('--primary-content'); C.dc = v('--danger-content'); C.sc = v('--success-content'); C.wc = v('--warning-content')
132
+ C.hi = v('--content-hi'); C.lo = v('--content-lo')
 
 
 
 
 
 
 
 
 
 
 
 
133
  if (horizLine) horizLine.setAttribute('stroke', rgba(C.pr, 0.4))
134
+ if (zoomVisualRaf) { cancelAnimationFrame(zoomVisualRaf); zoomVisualRaf = 0 }
 
 
 
135
  buildWorldElements()
136
  buildZones()
137
  prevInFov = null
138
  syncThemedSceneStrokes()
139
+ if (panelStatsRaf) { cancelAnimationFrame(panelStatsRaf); panelStatsRaf = 0 }
 
 
 
140
  panelStatsPayload = null
141
  }
142
 
143
+ function initTheme() {
144
+ var btns = document.querySelectorAll('.theme-btn')
145
+ function applyTheme(pref) {
146
+ var sim = document.querySelector('.coastal-surveillance-sim')
147
+ sim.dataset.theme = pref === 'system' ? (systemQuery.matches ? 'light' : 'dark') : pref
148
+ loadColors()
149
+ for (var i = 0; i < btns.length; i++)
150
+ btns[i].classList.toggle('active', btns[i].dataset.themeVal === pref)
151
+ }
152
+ applyTheme(localStorage.getItem('theme') || 'system')
153
+ systemQuery.addEventListener('change', function () {
154
+ if (localStorage.getItem('theme') === 'system') applyTheme('system')
155
+ })
156
+ for (var j = 0; j < btns.length; j++)
157
+ btns[j].addEventListener('click', function () {
158
+ localStorage.setItem('theme', this.dataset.themeVal)
159
+ applyTheme(this.dataset.themeVal)
160
+ })
161
  }
162
 
163
  function resetStreaks() {
164
+ detectedStreak = blindStreak = lastDetectedStreak = lastBlindStreak = 0
165
+ prevDetectedStreak = prevBlindStreak = prevVisible = null
166
+ }
167
+
168
+ function clearTrail(resetIconAngle) {
169
+ trailFlat.length = 0
170
+ if (resetIconAngle !== false) lastTrailIconAngle = NaN
171
+ if (boatTrail) boatTrail.setAttribute('points', '')
172
+ }
173
+
174
+ function flushTrailPoly() {
175
+ if (!boatTrail || !trailFlat.length) return
176
+ var s = trailFlat[0] + ',' + trailFlat[1]
177
+ for (var i = 2; i < trailFlat.length; i += 2)
178
+ s += ' ' + trailFlat[i] + ',' + trailFlat[i + 1]
179
+ boatTrail.setAttribute('points', s)
180
+ }
181
+
182
+ function appendTrail(bx, by) {
183
+ var m = trailFlat.length
184
+ if (m >= 2) {
185
+ var lx = trailFlat[m - 2]
186
+ var ly = trailFlat[m - 1]
187
+ if (Math.hypot(bx - lx, by - ly) < TRAIL_MIN_DIST) return
188
+ }
189
+ trailFlat.push(bx, by)
190
+ while (trailFlat.length > TRAIL_CAP * 2) {
191
+ trailFlat.shift()
192
+ trailFlat.shift()
193
+ }
194
+ flushTrailPoly()
195
+ }
196
+
197
  function sectorPath(fromDeg, toDeg, R, maxSteps) {
198
  var span = ((toDeg - fromDeg) + 360) % 360
199
  var steps = Math.ceil(span / 2)
200
+ steps = maxSteps != null && maxSteps > 0
201
+ ? Math.max(4, Math.min(maxSteps, Math.max(steps, 4)))
202
+ : Math.max(64, steps)
 
203
  var d = 'M 0 0'
204
  for (var i = 0; i <= steps; i++) {
205
  var deg = fromDeg + (span / steps) * i
 
209
  return d + ' Z'
210
  }
211
 
 
212
  function zoneFillRadiusWorld() {
213
  if (SCALE === 0) return 0.1
214
  function distWorld(px, py) {
215
+ return Math.hypot((px - camCX) / SCALE, (camCY - py) / SCALE)
 
 
216
  }
217
+ return Math.max(Math.max(distWorld(0, 0), distWorld(W, 0), distWorld(W, H), distWorld(0, H)) * 1.08, 0.04)
 
 
 
 
 
 
 
218
  }
219
 
 
220
  function coneRadiusWorld() {
221
  if (SCALE === 0) return 0.1
222
+ return Math.max(zoneFillRadiusWorld(), Math.hypot(W, H) / SCALE * 1.12)
 
223
  }
224
 
 
225
  function syncConeAndPointerShape(p) {
226
  if (!coneEl || SCALE === 0) return
227
+ var fov = p ? p.fov : +inpFov.value
228
  var R = coneRadiusWorld()
229
  var ptrLen = 32 / SCALE
230
+ if (fov === cachedConeFov && Math.abs(R - cachedConeR) < 1e-6 && Math.abs(ptrLen - cachedPtrLen) < 1e-12) return
 
231
  cachedConeFov = fov
232
  cachedConeR = R
233
  cachedPtrLen = ptrLen
 
246
  camDot.setAttribute('stroke', rgba(C.pr, 0.5))
247
  trajLine.setAttribute('stroke', rgba(C.dr, 0.25))
248
  bearingLine.setAttribute('stroke', rgba(C.dr, 0.12))
249
+ for (var i = 0; i < 4; i++) camCorners[i].setAttribute('stroke', rgba(C.pr, 0.5))
250
+ anchorSOuter.setAttribute('stroke', rgba(C.dr, 0.72))
251
+ anchorS.setAttribute('fill', rgba(C.dr, 0.52))
252
+ anchorS.setAttribute('stroke', rgba(C.dr, 0.45))
253
+ anchorEOuter.setAttribute('stroke', rgba(C.wr, 0.72))
254
+ anchorE.setAttribute('fill', rgba(C.wr, 0.48))
255
+ anchorE.setAttribute('stroke', rgba(C.wr, 0.42))
256
+ anchorSLabel.setAttribute('fill', rgba(C.dr, 0.88))
257
+ anchorELabel.setAttribute('fill', rgba(C.wr, 0.88))
258
+ if (boatTrail) boatTrail.setAttribute('stroke', rgba(C.dr, 0.55))
259
  }
260
 
261
  function updateTrackSvgGeomIfNeeded() {
262
  if (boatStart.x === lastTrackSx && boatStart.y === lastTrackSy &&
263
+ boatEnd.x === lastTrackEx && boatEnd.y === lastTrackEy) return
264
+ if (!isNaN(lastTrackSx) &&
265
+ (boatStart.x !== lastTrackSx || boatStart.y !== lastTrackSy ||
266
+ boatEnd.x !== lastTrackEx || boatEnd.y !== lastTrackEy))
267
+ clearTrail()
268
  lastTrackSx = boatStart.x
269
  lastTrackSy = boatStart.y
270
  lastTrackEx = boatEnd.x
271
  lastTrackEy = boatEnd.y
 
272
  trajLine.setAttribute('x1', boatStart.x)
273
  trajLine.setAttribute('y1', boatStart.y)
274
  trajLine.setAttribute('x2', boatEnd.x)
275
  trajLine.setAttribute('y2', boatEnd.y)
276
+ anchorSOuter.setAttribute('cx', boatStart.x)
277
+ anchorSOuter.setAttribute('cy', boatStart.y)
278
  anchorS.setAttribute('cx', boatStart.x)
279
  anchorS.setAttribute('cy', boatStart.y)
280
+ anchorEOuter.setAttribute('cx', boatEnd.x)
281
+ anchorEOuter.setAttribute('cy', boatEnd.y)
282
  anchorE.setAttribute('cx', boatEnd.x)
283
  anchorE.setAttribute('cy', boatEnd.y)
284
+ var syOff = 14 * MAP_LABEL_SCALE / SCALE
 
285
  anchorSLabel.setAttribute('x', boatStart.x)
286
  anchorSLabel.setAttribute('y', -(boatStart.y - syOff))
287
  anchorELabel.setAttribute('x', boatEnd.x)
 
290
 
291
  function updateScaleOnlySvgGeom() {
292
  var cr = 8 / SCALE
 
 
293
  var cornerPx = 14 / SCALE
294
+ camDot.setAttribute('r', cr)
 
 
 
 
 
295
  for (var ci = 0; ci < 4; ci++) {
296
+ var wx = (ci === 0 || ci === 3 ? -1 : 1) * cornerPx
297
+ var wy = (ci < 2 ? 1 : -1) * cornerPx
298
  camCorners[ci].setAttribute('x1', wx * 0.4)
299
  camCorners[ci].setAttribute('y1', wy * 0.4)
300
  camCorners[ci].setAttribute('x2', wx)
301
  camCorners[ci].setAttribute('y2', wy)
302
  }
303
+ var rHit = 6 / SCALE
304
+ var rRing = 10 / SCALE
305
+ anchorS.setAttribute('r', rHit)
306
+ anchorSOuter.setAttribute('r', rRing)
307
+ anchorE.setAttribute('r', rHit)
308
+ anchorEOuter.setAttribute('r', rRing)
309
+ boatHalo.setAttribute('r', 22 / SCALE)
310
+ var fsA = 11 * MAP_LABEL_SCALE / SCALE
311
  anchorSLabel.setAttribute('font-size', fsA)
312
  anchorELabel.setAttribute('font-size', fsA)
313
+ var iconPx = 15 / SCALE
314
+ boatIconScaleG.setAttribute('transform', 'scale(' + iconPx + ')')
 
315
  syncConeAndPointerShape(null)
316
+ lastTrackSx = NaN
 
317
  }
318
 
319
+ function buildLineZone(x1, y1, x2, y2) {
320
+ return svgEl('line', {
321
+ x1: x1, y1: y1, x2: x2, y2: y2,
322
+ stroke: rgba(C.pr, 0.5), 'stroke-width': 1, 'stroke-dasharray': '5 5',
323
+ 'vector-effect': 'non-scaling-stroke',
324
+ })
325
  }
326
 
327
  function buildZones() {
328
  if (!zonesG || SCALE === 0) return
329
  while (zonesG.firstChild) zonesG.removeChild(zonesG.firstChild)
330
+ var frontArc = +inpFrontArc.value
331
+ var fh = frontArc / 2
332
  var R = zoneFillRadiusWorld()
333
+ zonesG.appendChild(svgEl('path', { d: sectorPath(-fh, fh, R), fill: rgba(C.pr, 0.07), stroke: 'none' }))
334
+ zonesG.appendChild(svgEl('path', { d: sectorPath(fh, 360 - fh, R), fill: rgba(C.dr, 0.06), stroke: 'none' }))
335
+ var e1r = (-fh) * Math.PI / 180
336
+ var e2r = fh * Math.PI / 180
337
+ zonesG.appendChild(buildLineZone(0, 0, Math.sin(e1r) * R, Math.cos(e1r) * R))
338
+ zonesG.appendChild(buildLineZone(0, 0, Math.sin(e2r) * R, Math.cos(e2r) * R))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  var labelR = 80 / SCALE
340
+ var fs = 11 * MAP_LABEL_SCALE / SCALE
341
+ var tf = svgEl('text', { x: 0, y: -labelR, transform: 'scale(1,-1)',
342
+ fill: C.pc, 'font-size': fs, 'font-family': FONT_SANS, 'text-anchor': 'middle' })
343
+ tf.textContent = 'FRONT ' + frontArc + '\u00b0'
344
+ zonesG.appendChild(tf)
345
+ var tb = svgEl('text', { x: 0, y: 18 * MAP_LABEL_SCALE / SCALE, transform: 'scale(1,-1)',
346
+ fill: C.dc, 'font-size': fs, 'font-family': FONT_SANS, 'text-anchor': 'middle' })
347
+ tb.textContent = 'BACK ' + (360 - frontArc) + '\u00b0'
348
+ zonesG.appendChild(tb)
349
+ var edgeTxt = '\u00b1' + fh.toFixed(0) + '\u00b0'
350
+ for (var ei = 0; ei < 2; ei++) {
351
+ var rad = ei ? e2r : e1r
352
+ var tx = svgEl('text', {
353
+ x: Math.sin(rad) * labelR, y: -(Math.cos(rad) * labelR),
354
+ transform: 'scale(1,-1)', fill: C.lo,
355
+ 'font-size': fs, 'font-family': FONT_SANS, 'text-anchor': 'middle',
356
+ })
357
+ tx.textContent = edgeTxt
358
+ zonesG.appendChild(tx)
359
+ }
 
 
 
 
 
 
 
 
360
  }
361
 
362
  function fmtDist(d) {
363
  return d < 1 ? Math.round(d * 1000) + 'm' : d.toFixed(d < 10 ? 2 : 1).replace(/\.0+$/, '') + 'km'
364
  }
365
 
 
 
366
  function buildWorldElements() {
367
  if (!ringsG || !compassG || SCALE === 0) return
 
 
368
  while (ringsG.firstChild) ringsG.removeChild(ringsG.firstChild)
369
+ var nice = [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0]
370
+ var ringInterval = nice[0]
371
+ for (var i = 0; i < nice.length; i++) {
372
+ var px = nice[i] * SCALE
373
+ if (px >= 100 && px <= 240) { ringInterval = nice[i]; break }
374
+ if (px < 100) ringInterval = nice[i]
 
 
375
  }
376
  var maxDist = Math.sqrt(Math.pow(Math.max(camCX, W - camCX), 2) + Math.pow(camCY, 2)) / SCALE
377
+ var maxRing = Math.ceil(maxDist / ringInterval) + 1
378
+ var fs = 12 * MAP_LABEL_SCALE / SCALE
379
+ var tw = 5 / SCALE
380
+ for (var ri = 1; ri <= maxRing; ri++) {
381
  var r = ri * ringInterval
382
  ringsG.appendChild(svgEl('circle', { cx: 0, cy: 0, r: r, fill: 'none',
383
  stroke: rgba(C.pr, 0.25), 'stroke-width': 0.5, 'vector-effect': 'non-scaling-stroke' }))
 
 
384
  ringsG.appendChild(svgEl('line', { x1: -tw, y1: r, x2: tw, y2: r,
385
  stroke: rgba(C.pr, 0.7), 'stroke-width': 1, 'vector-effect': 'non-scaling-stroke' }))
386
+ var lbl = svgEl('text', { x: 8 * MAP_LABEL_SCALE / SCALE, y: -r, transform: 'scale(1,-1)',
387
+ fill: C.pc, 'font-size': fs, 'font-family': FONT_SANS })
388
+ lbl.textContent = fmtDist(r)
 
389
  ringsG.appendChild(lbl)
390
  }
391
 
 
392
  while (compassG.firstChild) compassG.removeChild(compassG.firstChild)
 
393
  var compassR = Math.min(camCX * 0.88, camCY * 0.92) / SCALE
394
+ var fsC = 11 * MAP_LABEL_SCALE / SCALE
 
 
395
  compassG.appendChild(svgEl('line', { x1: 0, y1: 0, x2: 0, y2: compassR + 20 / SCALE,
396
  stroke: rgba(C.pr, 0.2), 'stroke-width': 0.5, 'stroke-dasharray': (4 / SCALE) + ' ' + (4 / SCALE),
397
  'vector-effect': 'non-scaling-stroke' }))
 
398
  for (var deg = 0; deg < 360; deg += 10) {
399
+ var maj = deg % 30 === 0
400
  var rad = deg * Math.PI / 180
401
  var wx0 = Math.sin(rad) * compassR
402
  var wy0 = Math.cos(rad) * compassR
403
+ var tlen = (maj ? 8 : 4) / SCALE
 
 
404
  compassG.appendChild(svgEl('line', {
405
+ x1: wx0, y1: wy0, x2: Math.sin(rad) * (compassR + tlen), y2: Math.cos(rad) * (compassR + tlen),
406
+ stroke: rgba(C.pr, maj ? 0.65 : 0.35),
407
+ 'stroke-width': maj ? 1 : 0.5,
408
+ 'vector-effect': 'non-scaling-stroke',
409
  }))
410
+ if (maj) {
411
+ var lr = compassR + (8 + 10) * MAP_LABEL_SCALE / SCALE
412
+ var t = svgEl('text', { x: Math.sin(rad) * lr, y: -Math.cos(rad) * lr, transform: 'scale(1,-1)',
413
+ fill: C.lo, 'font-size': fsC, 'font-family': FONT_SANS, 'text-anchor': 'middle', 'dominant-baseline': 'middle' })
 
 
 
414
  t.textContent = deg + '\u00b0'
415
  compassG.appendChild(t)
416
  }
 
418
  }
419
 
420
  function updateWorldTransform() {
421
+ if (worldG) worldG.setAttribute('transform',
 
422
  'translate(' + camCX + ',' + camCY + ') scale(' + SCALE + ',' + (-SCALE) + ')')
423
  }
424
 
425
  function refreshSvgHitBox() {
426
  if (!svg || W <= 0 || H <= 0) return
427
  var r = svg.getBoundingClientRect()
428
+ if (r.width < 1 || r.height < 1) return
 
 
429
  hitL = r.left
430
  hitT = r.top
431
+ hitSx = W / r.width
432
+ hitSy = H / r.height
433
+ }
434
+
435
+ function rebuildWorldSvg() {
436
+ buildWorldElements()
437
+ buildZones()
438
+ if (coneEl && SCALE > 0) {
439
+ updateScaleOnlySvgGeom()
440
+ lastDrawScale = SCALE
441
+ }
442
+ refreshSvgHitBox()
443
  }
444
 
445
  function resize() {
 
449
  svg.setAttribute('width', W)
450
  svg.setAttribute('height', H)
451
  camCX = W / 2
452
+ camCY = H * 0.9
453
  SCALE = Math.min(W, H) * 0.42 * zoomLevel
454
  updateWorldTransform()
455
  if (bgSky) { bgSky.setAttribute('width', W); bgSky.setAttribute('height', camCY) }
456
  if (bgSea) { bgSea.setAttribute('y', camCY); bgSea.setAttribute('width', W); bgSea.setAttribute('height', H - camCY) }
457
  if (horizLine) { horizLine.setAttribute('x2', W); horizLine.setAttribute('y1', camCY); horizLine.setAttribute('y2', camCY) }
458
+ rebuildWorldSvg()
 
 
 
 
 
 
459
  }
460
 
461
  function updateZoomLabel() {
 
466
  function flushZoomVisuals() {
467
  zoomVisualRaf = 0
468
  if (!svg || SCALE <= 0) return
469
+ rebuildWorldSvg()
 
 
 
 
 
 
470
  }
471
 
472
  function scheduleZoomVisuals() {
473
+ if (!zoomVisualRaf) zoomVisualRaf = requestAnimationFrame(flushZoomVisuals)
 
474
  }
475
 
476
  function applyZoom(delta) {
 
506
  return Math.abs(h) <= arc / 2
507
  }
508
 
 
 
 
 
509
  function setDir(d) {
510
  camDir = d
511
  var cw = $('dir-cw')
 
514
  if (ccw) ccw.classList.toggle('active', d === -1)
515
  }
516
 
 
 
 
 
517
  function isBoatVisible(bx, by, heading, fovVal) {
518
+ var boatB = (Math.atan2(bx, by) * 180) / Math.PI
519
+ /* Cone uses rotate(heading) inside worldG scale(S,-S); boresight world bearing = -heading in this atan2 convention. */
520
+ var rel = (((boatB + heading + 540) % 360) - 180)
521
  return Math.abs(rel) <= fovVal / 2 && by > -0.05
522
  }
523
 
 
529
  }
530
  updateTrackSvgGeomIfNeeded()
531
  syncConeAndPointerShape(p)
 
532
  dynamicCamG.setAttribute('transform', 'rotate(' + camHeading + ')')
533
 
534
+ var segX = boatEnd.x - boatStart.x
535
+ var segY = boatEnd.y - boatStart.y
536
+ var udx = segX * boatDir
537
+ var udy = segY * boatDir
538
+ var pl = Math.hypot(segX, segY)
539
+ /* Bow is drawn along +icon Y (world north). worldG uses scale(S,-S), so screen dir ∝ (udx,-udy). */
540
+ var iconAngle = 0
541
+ if (pl > 1e-5) {
542
+ iconAngle = (Math.atan2(udy, udx) * 180) / Math.PI - 90
543
+ lastTrailIconAngle = iconAngle
544
+ } else if (!isNaN(lastTrailIconAngle)) iconAngle = lastTrailIconAngle
545
+ boatIconG.setAttribute('transform', 'rotate(' + iconAngle + ')')
546
+
547
  if (bx !== lastDrawBx || by !== lastDrawBy) {
548
  lastDrawBx = bx
549
  lastDrawBy = by
 
551
  bearingLine.setAttribute('y2', by)
552
  boatG.setAttribute('transform', 'translate(' + bx + ',' + by + ')')
553
  }
 
554
  if (prevInFov !== inFov) {
555
  prevInFov = inFov
556
+ boatHull.setAttribute('fill', inFov ? C.s : C.d)
557
+ boatHull.setAttribute('stroke', inFov ? rgba(C.sr, 0.9) : rgba(C.dr, 0.85))
 
558
  }
 
559
  if (inFov) {
560
  boatHalo.setAttribute('display', '')
561
+ boatHalo.setAttribute('fill', rgba(C.sr, 0.14))
562
+ } else boatHalo.setAttribute('display', 'none')
 
 
563
  }
564
 
565
  function schedulePanelStats(p, bx, by, inFov) {
566
  panelStatsPayload = { p: p, bx: bx, by: by, inFov: inFov }
567
+ if (!panelStatsRaf) panelStatsRaf = requestAnimationFrame(function () {
568
+ panelStatsRaf = 0
569
+ if (!panelStatsPayload) return
570
+ var q = panelStatsPayload
571
+ panelStatsPayload = null
572
+ updateStats(q.p, q.bx, q.by, q.inFov)
573
+ })
 
 
 
574
  }
575
 
576
  function updateStats(p, bx, by, inFov) {
 
 
 
577
  var c = statCache
578
+ var dist = Math.hypot(bx, by)
579
+ var st = p.frontArc / p.frontSpeed + (360 - p.frontArc) / p.backSpeed
580
+
581
+ function u(el, k, s) {
582
+ if (el && c[k] !== s) { el.textContent = s; c[k] = s }
583
+ }
584
 
585
+ u(c.elScan, 'lastScan', st.toFixed(1) + 's')
586
+ u(c.elDist, 'lastDist', (dist * 1000).toFixed(0) + 'm')
 
 
 
 
587
 
588
  var travelM = (inFov ? detectedStreak : lastDetectedStreak).toFixed(1) + 'm'
589
  var blindM = (!inFov ? blindStreak : lastBlindStreak).toFixed(1) + 'm'
590
  if (c.elTravel) {
591
+ u(c.elTravel, 'lastTravel', travelM)
592
  var to = inFov ? '1' : '0.5'
593
  if (c.lastTravelOp !== to) { c.elTravel.style.opacity = to; c.lastTravelOp = to }
594
  }
595
  if (c.elBlind) {
596
+ u(c.elBlind, 'lastBlind', blindM)
597
  var bo = !inFov ? '1' : '0.5'
598
  if (c.lastBlindOp !== bo) { c.elBlind.style.opacity = bo; c.lastBlindOp = bo }
599
  }
600
 
601
+ u(c.elTravelPrev, 'lastTravelPrev', prevDetectedStreak > 0 ? 'prev: ' + prevDetectedStreak.toFixed(1) + 'm' : 'prev: β€”')
602
+ u(c.elBlindPrev, 'lastBlindPrev', prevBlindStreak > 0 ? 'prev: ' + prevBlindStreak.toFixed(1) + 'm' : 'prev: β€”')
 
 
603
 
604
+ var prevTotalM = prevDetectedStreak + prevBlindStreak
605
  if (c.elRisk && c.elRiskBar) {
606
+ if (prevTotalM >= MIN_BLIND_SHARE_M) {
607
+ var share = (prevBlindStreak / prevTotalM) * 100
608
+ var shareT = share.toFixed(0) + '%'
609
+ var col = share > 66 ? 'var(--danger)' : share > 33 ? 'var(--warning)' : 'var(--success)'
610
+ var w = share + '%'
611
+ u(c.elRisk, 'lastRiskT', shareT)
612
  if (c.lastRiskCol !== col) { c.elRisk.style.color = col; c.lastRiskCol = col }
613
  if (c.lastRiskW !== w) { c.elRiskBar.style.width = w; c.lastRiskW = w }
614
  if (c.lastRiskBg !== col) { c.elRiskBar.style.background = col; c.lastRiskBg = col }
615
  } else {
616
+ u(c.elRisk, 'lastRiskT', 'β€”')
617
  if (c.lastRiskW !== '0%') { c.elRiskBar.style.width = '0%'; c.lastRiskW = '0%' }
618
  }
619
  }
620
  var vis = inFov ? 'YES' : 'NO'
621
  var visCls = 'val ' + (inFov ? 'active' : 'inactive')
622
  if (c.elVis) {
623
+ u(c.elVis, 'lastVis', vis)
624
  if (c.lastVisCls !== visCls) { c.elVis.className = visCls; c.lastVisCls = visCls }
625
  }
626
  }
627
 
628
  function animate(ts) {
629
  if (!running || !svg) return
 
630
  if (lastTime === null) lastTime = ts
631
  var dt = Math.min((ts - lastTime) / 1000, 0.1)
632
  lastTime = ts
 
633
  var p = getParams()
 
634
  var boatKmS = p.boatSpeed * 0.000514
635
  var pathLen = Math.hypot(boatEnd.x - boatStart.x, boatEnd.y - boatStart.y)
636
  if (pathLen > 0.001) {
637
+ boatT += boatDir * (boatKmS * dt) / pathLen
638
+ if (boatT >= 1) { boatT = 1; boatDir = -1 }
639
+ if (boatT <= 0) { boatT = 0; boatDir = 1 }
 
 
 
 
 
 
 
640
  }
641
+ var speed = isFront(camHeading, p.frontArc) ? p.frontSpeed : p.backSpeed
 
 
642
  camHeading -= camDir * speed * dt
643
  if (camHeading >= 180) camHeading -= 360
644
  if (camHeading < -180) camHeading += 360
 
645
  var bx = boatStart.x + (boatEnd.x - boatStart.x) * boatT
646
  var by = boatStart.y + (boatEnd.y - boatStart.y) * boatT
647
  var visible = isBoatVisible(bx, by, camHeading, p.fov)
 
648
  var distThisFrame = p.boatSpeed * 0.514 * dt
649
  if (prevVisible !== null && visible !== prevVisible) {
650
  if (visible) {
651
+ clearTrail(false)
652
  prevBlindStreak = blindStreak
653
  lastBlindStreak = blindStreak
654
  blindStreak = 0
 
662
  else blindStreak += distThisFrame
663
  prevVisible = visible
664
 
665
+ if (coneEl && SCALE > 0 && pathLen > 0.001 && !visible) appendTrail(bx, by)
666
+
667
+ if (!coneEl || SCALE === 0) updateStats(p, bx, by, visible)
668
+ else {
669
  draw(p, bx, by, visible)
670
  schedulePanelStats(p, bx, by, visible)
671
  }
672
+ if (running) requestAnimationFrame(animate)
673
  }
674
 
675
  function getCanvasXY(e) {
 
676
  var src = 'touches' in e && e.touches.length ? e.touches[0] : e
677
  return { x: (src.clientX - hitL) * hitSx, y: (src.clientY - hitT) * hitSy }
678
  }
679
 
680
  function onResize() {
681
+ if (!resizeRaf) resizeRaf = requestAnimationFrame(function () { resizeRaf = 0; resize() })
 
 
 
 
682
  }
683
 
684
+ function tryDragStart(e, thresh) {
 
685
  var pt = getCanvasXY(e)
686
+ var s = worldToCanvas(boatStart.x, boatStart.y)
687
+ var end = worldToCanvas(boatEnd.x, boatEnd.y)
688
+ if (Math.hypot(pt.x - s.x, pt.y - s.y) < thresh) dragging = 'start'
689
+ else if (Math.hypot(pt.x - end.x, pt.y - end.y) < thresh) dragging = 'end'
690
  }
691
 
692
+ function dragToWorld(e) {
 
693
  var pt = getCanvasXY(e)
694
  var w = canvasToWorld(pt.x, pt.y)
695
  w.y = Math.max(0.05, Math.min(1.3, w.y))
696
  w.x = Math.max(-1.4, Math.min(1.4, w.x))
697
  if (dragging === 'start') boatStart = w
698
+ else if (dragging === 'end') boatEnd = w
699
+ }
700
+
701
+ function onMouseDown(e) {
702
+ lastPointerEv = e
703
+ tryDragStart(e, 26)
704
+ updateSvgCursor(e)
705
+ }
706
+
707
+ var CURSOR_NEAR = 30
708
+ var lastPointerEv = null
709
+
710
+ function updateSvgCursor(e) {
711
+ if (!svg || !SCALE) return
712
+ if (dragging) {
713
+ svg.style.cursor = 'grabbing'
714
+ return
715
+ }
716
+ var pt = getCanvasXY(e)
717
+ var s = worldToCanvas(boatStart.x, boatStart.y)
718
+ var end = worldToCanvas(boatEnd.x, boatEnd.y)
719
+ if (Math.hypot(pt.x - s.x, pt.y - s.y) < CURSOR_NEAR ||
720
+ Math.hypot(pt.x - end.x, pt.y - end.y) < CURSOR_NEAR)
721
+ svg.style.cursor = 'grab'
722
+ else svg.style.cursor = ''
723
+ }
724
+
725
+ function onMouseMove(e) {
726
+ lastPointerEv = e
727
+ if (dragging) dragToWorld(e)
728
+ updateSvgCursor(e)
729
  }
730
 
731
  function onMouseUp() {
732
  dragging = null
733
+ if (svg) {
734
+ if (lastPointerEv) updateSvgCursor(lastPointerEv)
735
+ else svg.style.cursor = ''
736
+ }
737
  }
738
 
739
  function onTouchStart(e) {
740
  if (e.touches.length === 2) {
741
  lastPinchDist = Math.hypot(
742
  e.touches[0].clientX - e.touches[1].clientX,
743
+ e.touches[0].clientY - e.touches[1].clientY)
 
744
  dragging = null
745
  return
746
  }
747
  if (e.touches.length !== 1) return
748
  e.preventDefault()
749
+ tryDragStart(e, 34)
 
 
 
 
750
  }
751
 
752
  function onTouchMove(e) {
 
754
  e.preventDefault()
755
  var dist = Math.hypot(
756
  e.touches[0].clientX - e.touches[1].clientX,
757
+ e.touches[0].clientY - e.touches[1].clientY)
758
+ applyZoom(dist - lastPinchDist)
 
 
759
  lastPinchDist = dist
760
  return
761
  }
762
  if (!dragging) return
763
  e.preventDefault()
764
+ dragToWorld(e)
 
 
 
 
 
765
  }
766
 
767
  function onTouchEnd(e) {
 
777
  function bindRange(id, valSpanId, onInput) {
778
  var input = $(id)
779
  var span = valSpanId ? $(valSpanId) : null
780
+ function sync() { if (span) span.textContent = input.value }
 
 
781
  input.addEventListener('input', function () {
782
  sync()
783
  resetStreaks()
 
786
  sync()
787
  }
788
 
789
+ /** VOS #1 = shipped defaults; proposal #2 uses 50Β° FOV (slider is degrees, not a zoom %). */
790
+ var CAMERA_PRESETS = {
791
+ vos1: { fov: 18, frontSpeed: 6, backSpeed: 60, frontArc: 180 },
792
+ prop1: { fov: 18, frontSpeed: 7, backSpeed: 120, frontArc: 125 },
793
+ prop2: { fov: 50, frontSpeed: 8, backSpeed: 120, frontArc: 125 },
794
+ }
795
+
796
+ function clearPresetDropdown() {
797
+ var sel = $('camera-preset')
798
+ if (sel) sel.value = ''
799
+ }
800
+
801
+ function applyCameraPreset(key) {
802
+ var p = CAMERA_PRESETS[key]
803
+ if (!p || !inpFov) return
804
+ inpFov.value = String(p.fov)
805
+ inpFrontSpeed.value = String(p.frontSpeed)
806
+ inpBackSpeed.value = String(p.backSpeed)
807
+ inpFrontArc.value = String(p.frontArc)
808
+ $('val-fov').textContent = inpFov.value
809
+ $('val-front-speed').textContent = inpFrontSpeed.value
810
+ $('val-back-speed').textContent = inpBackSpeed.value
811
+ $('val-front-arc').textContent = inpFrontArc.value
812
+ cachedConeFov = cachedPtrLen = NaN
813
+ resetStreaks()
814
+ buildZones()
815
+ var sel = $('camera-preset')
816
+ if (sel) sel.value = key
817
+ }
818
+
819
  function init() {
820
  svg = $('c')
821
  wrap = $('canvas-wrap')
822
  if (!svg || !wrap) return
823
 
 
824
  bgSky = svgEl('rect', { x: 0, y: 0, fill: 'var(--surface-1)' })
825
  bgSea = svgEl('rect', { x: 0, y: 0, fill: 'var(--surface-2)' })
826
  horizLine = svgEl('line', { x1: 0, 'stroke-dasharray': '6 4' })
 
833
  worldG.appendChild(ringsG)
834
 
835
  dynamicCamG = svgEl('g', { id: 'dynamic-cam' })
836
+ coneEl = svgEl('path', { 'stroke-linejoin': 'round', 'stroke-width': 1, 'vector-effect': 'non-scaling-stroke' })
837
+ camPointer = svgEl('line', { x1: 0, y1: 0, x2: 0, y2: 0.1, 'stroke-width': 1.5, 'vector-effect': 'non-scaling-stroke' })
 
 
 
 
 
838
  dynamicCamG.appendChild(coneEl)
839
  dynamicCamG.appendChild(camPointer)
840
+ trajLine = svgEl('line', { 'stroke-width': 1, 'stroke-dasharray': '4 3', 'vector-effect': 'non-scaling-stroke' })
841
+ boatTrail = svgEl('polyline', {
842
+ fill: 'none', 'stroke-width': 2, 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
843
+ 'vector-effect': 'non-scaling-stroke',
 
844
  })
845
+ bearingLine = svgEl('line', { x1: 0, y1: 0, 'stroke-width': 0.5, 'vector-effect': 'non-scaling-stroke' })
846
  boatG = svgEl('g', { id: 'boat-layer' })
847
  boatHalo = svgEl('circle', { cx: 0, cy: 0, stroke: 'none' })
848
+ boatIconG = svgEl('g')
849
+ boatIconScaleG = svgEl('g')
850
+ boatHull = svgEl('path', {
851
+ d: 'M 0 1.05 L 0.38 0.22 L 0.26 -0.72 L -0.26 -0.72 L -0.38 0.22 Z',
852
+ 'stroke-width': 1.2, 'stroke-linejoin': 'round', 'vector-effect': 'non-scaling-stroke',
 
853
  })
854
+ boatIconScaleG.appendChild(boatHull)
855
+ boatIconG.appendChild(boatIconScaleG)
856
  boatG.appendChild(boatHalo)
857
+ boatG.appendChild(boatIconG)
858
+ anchorSOuter = svgEl('circle', {
859
+ class: 'drag-anchor-ring drag-anchor-ring--start',
860
+ fill: 'none', 'stroke-width': 2.5, 'vector-effect': 'non-scaling-stroke',
861
+ })
862
+ anchorS = svgEl('circle', { class: 'drag-anchor-core drag-anchor-core--start', 'stroke-width': 1.5, 'vector-effect': 'non-scaling-stroke' })
863
+ anchorEOuter = svgEl('circle', {
864
+ class: 'drag-anchor-ring drag-anchor-ring--end',
865
+ fill: 'none', 'stroke-width': 2.5, 'vector-effect': 'non-scaling-stroke',
866
+ })
867
+ anchorE = svgEl('circle', { class: 'drag-anchor-core drag-anchor-core--end', 'stroke-width': 1.5, 'vector-effect': 'non-scaling-stroke' })
868
  anchorSLabel = svgEl('text', {
869
+ class: 'drag-anchor-label',
870
+ 'text-anchor': 'middle', 'dominant-baseline': 'middle', transform: 'scale(1,-1)', 'font-family': FONT_SANS,
871
  })
872
  anchorSLabel.textContent = 'S'
873
  anchorELabel = svgEl('text', {
874
+ class: 'drag-anchor-label drag-anchor-label--end',
875
+ 'text-anchor': 'middle', 'dominant-baseline': 'middle', transform: 'scale(1,-1)', 'font-family': FONT_SANS,
876
  })
877
  anchorELabel.textContent = 'E'
878
+ camDot = svgEl('circle', { cx: 0, cy: 0, 'stroke-width': 1.5, 'vector-effect': 'non-scaling-stroke' })
879
+ for (var ci = 0; ci < 4; ci++) camCorners.push(svgEl('line', { 'stroke-width': 1, 'vector-effect': 'non-scaling-stroke' }))
 
 
 
880
 
881
  worldG.appendChild(dynamicCamG)
882
  worldG.appendChild(trajLine)
883
+ worldG.appendChild(boatTrail)
884
  worldG.appendChild(bearingLine)
885
  worldG.appendChild(boatG)
886
+ worldG.appendChild(anchorSOuter)
887
  worldG.appendChild(anchorS)
888
+ worldG.appendChild(anchorEOuter)
889
  worldG.appendChild(anchorE)
890
  worldG.appendChild(anchorSLabel)
891
  worldG.appendChild(anchorELabel)
 
902
  inpBackSpeed = $('input-back-speed')
903
  inpFrontArc = $('input-front-arc')
904
  inpBoatSpeed = $('input-boat-speed')
905
+ cachedConeFov = cachedConeR = cachedPtrLen = NaN
906
+ lastDrawBx = lastDrawBy = NaN
 
 
907
 
908
  initTheme()
909
  running = true
 
915
  svg.addEventListener('mousedown', onMouseDown)
916
  svg.addEventListener('mousemove', onMouseMove)
917
  svg.addEventListener('mouseup', onMouseUp)
918
+ svg.addEventListener('mouseleave', function () {
919
+ if (!dragging) svg.style.cursor = ''
920
+ })
921
  svg.addEventListener('touchstart', onTouchStart, { passive: false })
922
  svg.addEventListener('touchmove', onTouchMove, { passive: false })
923
  svg.addEventListener('touchend', onTouchEnd)
924
  svg.addEventListener('touchcancel', onTouchEnd)
925
  svg.addEventListener('wheel', onWheel, { passive: false })
926
 
927
+ $('zoom-out').addEventListener('click', function () { applyZoom(-1) })
928
+ $('zoom-in').addEventListener('click', function () { applyZoom(1) })
929
+ $('dir-cw').addEventListener('click', function () { setDir(1) })
930
+ $('dir-ccw').addEventListener('click', function () { setDir(-1) })
 
 
 
 
 
 
 
 
931
  bindRange('input-fov', 'val-fov', function () {
932
+ cachedConeFov = cachedPtrLen = NaN
933
+ clearPresetDropdown()
934
+ })
935
+ bindRange('input-front-speed', 'val-front-speed', clearPresetDropdown)
936
+ bindRange('input-back-speed', 'val-back-speed', clearPresetDropdown)
937
+ bindRange('input-front-arc', 'val-front-arc', function () {
938
+ clearPresetDropdown()
939
+ buildZones()
940
  })
 
 
 
941
  bindRange('input-boat-speed', 'val-boat-speed')
942
 
943
+ var presetSelect = $('camera-preset')
944
+ if (presetSelect) {
945
+ presetSelect.addEventListener('change', function () {
946
+ var k = presetSelect.value
947
+ if (k) applyCameraPreset(k)
948
+ })
949
+ }
950
+
951
  statCache.elScan = $('st-scan')
 
952
  statCache.elDist = $('st-dist')
953
  statCache.elTravel = $('st-travel')
954
  statCache.elBlind = $('st-blind')
 
958
  statCache.elRiskBar = $('st-risk-bar')
959
  statCache.elVis = $('st-vis')
960
 
961
+ requestAnimationFrame(animate)
962
  }
963
 
964
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init)
index.html CHANGED
@@ -17,11 +17,11 @@
17
  <div id="header">
18
  <span class="blink" aria-hidden="true"></span>
19
  <h1>Coastal Surveillance Sim</h1>
20
- <span class="sub">// PTZ camera model</span>
21
  <div class="spacer"></div>
22
  <div class="theme-toggle" id="theme-toggle">
23
  <button type="button" class="theme-btn" data-theme-val="light" title="Light theme" aria-label="Light theme">
24
- <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
25
  <circle cx="8" cy="8" r="3" stroke="currentColor" stroke-width="1.5"/>
26
  <line x1="8" y1="1" x2="8" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
27
  <line x1="8" y1="13" x2="8" y2="15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
@@ -34,12 +34,12 @@
34
  </svg>
35
  </button>
36
  <button type="button" class="theme-btn" data-theme-val="dark" title="Dark theme" aria-label="Dark theme">
37
- <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
38
  <path d="M13.5 10.5A6 6 0 0 1 5.5 2.5a6 6 0 1 0 8 8z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
39
  </svg>
40
  </button>
41
  <button type="button" class="theme-btn" data-theme-val="system" title="System theme" aria-label="System theme">
42
- <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
43
  <rect x="1" y="2" width="14" height="10" rx="1.5" stroke="currentColor" stroke-width="1.5"/>
44
  <line x1="5" y1="14" x2="11" y2="14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
45
  <line x1="8" y1="12" x2="8" y2="14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
@@ -65,10 +65,6 @@
65
  <div class="lbl">Scan time</div>
66
  <div id="st-scan" class="val">β€”</div>
67
  </div>
68
- <div class="stat">
69
- <div class="lbl">Cam heading</div>
70
- <div id="st-angle" class="val">β€”</div>
71
- </div>
72
  <div class="stat">
73
  <div class="lbl">Boat dist.</div>
74
  <div id="st-dist" class="val">β€”</div>
@@ -77,6 +73,15 @@
77
  <div class="lbl">Detected</div>
78
  <div id="st-vis" class="val">β€”</div>
79
  </div>
 
 
 
 
 
 
 
 
 
80
  </div>
81
  <div class="stats-extra">
82
  <div class="stat">
@@ -89,21 +94,21 @@
89
  <div id="st-blind" class="val st-sm">β€”</div>
90
  <div id="st-blind-prev" class="stat-sub">prev: β€”</div>
91
  </div>
92
- <div class="stat stat-risk">
93
- <div class="lbl">Blind risk</div>
94
- <div class="risk-row">
95
- <div id="st-risk" class="val st-risk-val">β€”</div>
96
- <div class="risk-track">
97
- <div id="st-risk-bar" class="risk-fill"></div>
98
- </div>
99
- </div>
100
- </div>
101
  </div>
102
  </div>
103
 
104
  <div class="section">
105
  <div class="section-title">Camera</div>
106
  <div class="section-body">
 
 
 
 
 
 
 
 
 
107
  <div class="ctrl">
108
  <label for="input-fov">FOV <span id="val-fov">18</span>Β°</label>
109
  <input id="input-fov" type="range" min="5" max="90" step="1" value="18" />
 
17
  <div id="header">
18
  <span class="blink" aria-hidden="true"></span>
19
  <h1>Coastal Surveillance Sim</h1>
20
+ <span class="sub">// COASTKEEPER - ONEBERRY</span>
21
  <div class="spacer"></div>
22
  <div class="theme-toggle" id="theme-toggle">
23
  <button type="button" class="theme-btn" data-theme-val="light" title="Light theme" aria-label="Light theme">
24
+ <svg width="18" height="18" viewBox="0 0 16 16" fill="none" aria-hidden="true">
25
  <circle cx="8" cy="8" r="3" stroke="currentColor" stroke-width="1.5"/>
26
  <line x1="8" y1="1" x2="8" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
27
  <line x1="8" y1="13" x2="8" y2="15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
 
34
  </svg>
35
  </button>
36
  <button type="button" class="theme-btn" data-theme-val="dark" title="Dark theme" aria-label="Dark theme">
37
+ <svg width="18" height="18" viewBox="0 0 16 16" fill="none" aria-hidden="true">
38
  <path d="M13.5 10.5A6 6 0 0 1 5.5 2.5a6 6 0 1 0 8 8z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
39
  </svg>
40
  </button>
41
  <button type="button" class="theme-btn" data-theme-val="system" title="System theme" aria-label="System theme">
42
+ <svg width="18" height="18" viewBox="0 0 16 16" fill="none" aria-hidden="true">
43
  <rect x="1" y="2" width="14" height="10" rx="1.5" stroke="currentColor" stroke-width="1.5"/>
44
  <line x1="5" y1="14" x2="11" y2="14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
45
  <line x1="8" y1="12" x2="8" y2="14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
 
65
  <div class="lbl">Scan time</div>
66
  <div id="st-scan" class="val">β€”</div>
67
  </div>
 
 
 
 
68
  <div class="stat">
69
  <div class="lbl">Boat dist.</div>
70
  <div id="st-dist" class="val">β€”</div>
 
73
  <div class="lbl">Detected</div>
74
  <div id="st-vis" class="val">β€”</div>
75
  </div>
76
+ <div class="stat stat-risk" title="Blind metres Γ· (blind + detected metres) from your last completed in-FOV and out-of-FOV spells. The bar fills only after their combined length reaches a minimum (avoids noisy early readings).">
77
+ <div class="lbl">Blind share</div>
78
+ <div class="risk-row">
79
+ <div id="st-risk" class="val st-risk-val">β€”</div>
80
+ <div class="risk-track">
81
+ <div id="st-risk-bar" class="risk-fill"></div>
82
+ </div>
83
+ </div>
84
+ </div>
85
  </div>
86
  <div class="stats-extra">
87
  <div class="stat">
 
94
  <div id="st-blind" class="val st-sm">β€”</div>
95
  <div id="st-blind-prev" class="stat-sub">prev: β€”</div>
96
  </div>
 
 
 
 
 
 
 
 
 
97
  </div>
98
  </div>
99
 
100
  <div class="section">
101
  <div class="section-title">Camera</div>
102
  <div class="section-body">
103
+ <div class="ctrl">
104
+ <label for="camera-preset">Presets</label>
105
+ <select id="camera-preset" class="preset-select" aria-label="Apply a camera preset">
106
+ <option value="" selected>β€”</option>
107
+ <option value="vos1">VOS #1 (default)</option>
108
+ <option value="prop1">Proposal #1</option>
109
+ <option value="prop2">Proposal #2</option>
110
+ </select>
111
+ </div>
112
  <div class="ctrl">
113
  <label for="input-fov">FOV <span id="val-fov">18</span>Β°</label>
114
  <input id="input-fov" type="range" min="5" max="90" step="1" value="18" />
style.css CHANGED
@@ -3,6 +3,8 @@ body,
3
  #app {
4
  height: 100%;
5
  margin: 0;
 
 
6
  }
7
 
8
  body {
@@ -55,14 +57,15 @@ body {
55
  box-sizing: border-box;
56
  width: 100%;
57
  height: 100%;
 
58
  background: var(--surface-1);
59
  color: var(--content-hi);
60
  font-family: var(--font);
61
- font-size: 14px;
62
  overflow: hidden;
63
  display: grid;
64
- grid-template-columns: 1fr 264px;
65
- grid-template-rows: 40px 1fr;
66
  }
67
 
68
  /* SEA.AI Design Tokens β€” LIGHT theme */
@@ -109,7 +112,7 @@ body {
109
  }
110
 
111
  #header h1 {
112
- font-size: 16px;
113
  font-weight: 500;
114
  letter-spacing: 0.02em;
115
  color: var(--content-hi);
@@ -118,7 +121,7 @@ body {
118
  }
119
 
120
  #header .sub {
121
- font-size: 12px;
122
  font-weight: 400;
123
  color: var(--content-lo);
124
  letter-spacing: 0.02em;
@@ -155,8 +158,8 @@ body {
155
  }
156
 
157
  .theme-btn {
158
- width: 28px;
159
- height: 28px;
160
  padding: 0;
161
  background: transparent;
162
  border: none;
@@ -180,11 +183,15 @@ body {
180
 
181
  #canvas-wrap {
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 {
@@ -193,6 +200,50 @@ body {
193
  height: 100%;
194
  overflow: visible;
195
  shape-rendering: optimizeSpeed;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  }
197
 
198
  /* ── Zoom bar ────────────────────────────────────────── */
@@ -203,28 +254,28 @@ body {
203
  right: var(--sp-m);
204
  display: flex;
205
  align-items: center;
206
- gap: var(--sp-xs);
207
  z-index: 10;
208
  }
209
 
210
  .zoom-label {
211
  font-family: var(--font);
212
- font-size: 12px;
213
  font-weight: 500;
214
  color: var(--content-mid);
215
- min-width: 36px;
216
  text-align: center;
217
  }
218
 
219
  .zoom-btn {
220
- width: 32px;
221
- height: 32px;
222
  background: var(--surface-3);
223
  border: 1px solid rgba(255, 255, 255, 0.1);
224
- border-radius: var(--radious-s);
225
  color: var(--content-mid);
226
  font-family: var(--font);
227
- font-size: 18px;
228
  font-weight: 400;
229
  cursor: pointer;
230
  display: flex;
@@ -245,20 +296,27 @@ body {
245
  background: var(--surface-2);
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 ────────────────────────────────────────── */
261
 
 
 
 
 
 
262
  .section {
263
  background: var(--surface-3);
264
  border-radius: var(--radious-m);
@@ -266,7 +324,7 @@ body {
266
  }
267
 
268
  .section-title {
269
- font-size: 12px;
270
  font-weight: 500;
271
  letter-spacing: 0;
272
  text-transform: uppercase;
@@ -293,7 +351,7 @@ body {
293
  }
294
 
295
  .stat .lbl {
296
- font-size: 12px;
297
  font-weight: 500;
298
  letter-spacing: 0;
299
  text-transform: uppercase;
@@ -302,7 +360,7 @@ body {
302
  }
303
 
304
  .stat .val {
305
- font-size: 20px;
306
  font-weight: 400;
307
  color: var(--content-hi);
308
  line-height: 1.2;
@@ -319,14 +377,10 @@ body {
319
  background: var(--surface-2);
320
  }
321
 
322
- .stats-extra .stat-risk {
323
- grid-column: 1 / -1;
324
- }
325
-
326
- .st-sm { font-size: 16px; }
327
 
328
  .stat-sub {
329
- font-size: 11px;
330
  font-weight: 400;
331
  color: var(--content-lo);
332
  margin-top: 2px;
@@ -340,8 +394,8 @@ body {
340
  }
341
 
342
  .st-risk-val {
343
- font-size: 16px;
344
- min-width: 44px;
345
  }
346
 
347
  .risk-track {
@@ -373,7 +427,7 @@ body {
373
  .ctrl label {
374
  display: flex;
375
  justify-content: space-between;
376
- font-size: 12px;
377
  font-weight: 500;
378
  color: var(--content-lo);
379
  margin-bottom: var(--sp-xs);
@@ -382,7 +436,7 @@ body {
382
 
383
  .ctrl label span {
384
  color: var(--content-hi);
385
- font-size: 14px;
386
  font-weight: 400;
387
  }
388
 
@@ -417,6 +471,38 @@ input[type='range']::-moz-range-thumb {
417
  border: none;
418
  }
419
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
  /* Direction toggle (panel only; no on-map indicator) */
421
  .dir-toggle {
422
  display: flex;
@@ -426,13 +512,13 @@ input[type='range']::-moz-range-thumb {
426
 
427
  .dir-btn {
428
  flex: 1;
429
- height: 40px;
430
  background: var(--surface-4);
431
  border: 1px solid rgba(255, 255, 255, 0.1);
432
  border-radius: var(--radious-s);
433
  color: var(--content-lo);
434
  font-family: var(--font);
435
- font-size: 14px;
436
  font-weight: 500;
437
  letter-spacing: 0.015em;
438
  cursor: pointer;
@@ -454,7 +540,7 @@ input[type='range']::-moz-range-thumb {
454
 
455
  /* Hint */
456
  .hint {
457
- font-size: 12px;
458
  font-weight: 400;
459
  color: var(--content-lo);
460
  line-height: 1.5;
@@ -469,7 +555,7 @@ input[type='range']::-moz-range-thumb {
469
  @media (max-width: 700px) {
470
  .coastal-surveillance-sim {
471
  grid-template-columns: 1fr;
472
- grid-template-rows: 40px 55vw 1fr;
473
  }
474
  #panel {
475
  border-left: none;
 
3
  #app {
4
  height: 100%;
5
  margin: 0;
6
+ /* Let nested grid/flex shrink below content so inner panes can scroll (incl. browser zoom). */
7
+ min-height: 0;
8
  }
9
 
10
  body {
 
57
  box-sizing: border-box;
58
  width: 100%;
59
  height: 100%;
60
+ min-height: 0;
61
  background: var(--surface-1);
62
  color: var(--content-hi);
63
  font-family: var(--font);
64
+ font-size: 16px;
65
  overflow: hidden;
66
  display: grid;
67
+ grid-template-columns: 1fr 304px;
68
+ grid-template-rows: 46px minmax(0, 1fr);
69
  }
70
 
71
  /* SEA.AI Design Tokens β€” LIGHT theme */
 
112
  }
113
 
114
  #header h1 {
115
+ font-size: 18px;
116
  font-weight: 500;
117
  letter-spacing: 0.02em;
118
  color: var(--content-hi);
 
121
  }
122
 
123
  #header .sub {
124
+ font-size: 14px;
125
  font-weight: 400;
126
  color: var(--content-lo);
127
  letter-spacing: 0.02em;
 
158
  }
159
 
160
  .theme-btn {
161
+ width: 32px;
162
+ height: 32px;
163
  padding: 0;
164
  background: transparent;
165
  border: none;
 
183
 
184
  #canvas-wrap {
185
  position: relative;
186
+ min-height: 0;
187
  background: var(--surface-1);
188
  overflow: hidden;
189
  /* Own stacking context so heavy SVG repaints don’t fight the grid panel (paint isolation). */
190
  isolation: isolate;
191
  z-index: 0;
192
+ /* Drags were selecting compass/ring labels as text; map chrome isn’t meant to be selectable. */
193
+ user-select: none;
194
+ -webkit-user-select: none;
195
  }
196
 
197
  #canvas-wrap svg {
 
200
  height: 100%;
201
  overflow: visible;
202
  shape-rendering: optimizeSpeed;
203
+ -webkit-user-select: none;
204
+ user-select: none;
205
+ }
206
+
207
+ /* Draggable anchor affordance β€” discrete pulse; opacity stacks with fill/stroke alphas from JS. */
208
+ @keyframes drag-anchor-ring-pulse {
209
+ 0%, 100% { opacity: 0.8; }
210
+ 50% { opacity: 1; }
211
+ }
212
+
213
+ @keyframes drag-anchor-core-pulse {
214
+ 0%, 100% { opacity: 0.9; }
215
+ 50% { opacity: 1; }
216
+ }
217
+
218
+ @keyframes drag-anchor-label-pulse {
219
+ 0%, 100% { opacity: 0.84; }
220
+ 50% { opacity: 1; }
221
+ }
222
+
223
+ #canvas-wrap svg .drag-anchor-ring {
224
+ animation: drag-anchor-ring-pulse 2.75s ease-in-out infinite;
225
+ }
226
+
227
+ #canvas-wrap svg .drag-anchor-ring--end,
228
+ #canvas-wrap svg .drag-anchor-core--end,
229
+ #canvas-wrap svg .drag-anchor-label--end {
230
+ animation-delay: 1.38s;
231
+ }
232
+
233
+ #canvas-wrap svg .drag-anchor-core {
234
+ animation: drag-anchor-core-pulse 2.75s ease-in-out infinite;
235
+ }
236
+
237
+ #canvas-wrap svg .drag-anchor-label {
238
+ animation: drag-anchor-label-pulse 2.75s ease-in-out infinite;
239
+ }
240
+
241
+ @media (prefers-reduced-motion: reduce) {
242
+ #canvas-wrap svg .drag-anchor-ring,
243
+ #canvas-wrap svg .drag-anchor-core,
244
+ #canvas-wrap svg .drag-anchor-label {
245
+ animation: none;
246
+ }
247
  }
248
 
249
  /* ── Zoom bar ────────────────────────────────────────── */
 
254
  right: var(--sp-m);
255
  display: flex;
256
  align-items: center;
257
+ gap: var(--sp-s);
258
  z-index: 10;
259
  }
260
 
261
  .zoom-label {
262
  font-family: var(--font);
263
+ font-size: 15px;
264
  font-weight: 500;
265
  color: var(--content-mid);
266
+ min-width: 50px;
267
  text-align: center;
268
  }
269
 
270
  .zoom-btn {
271
+ width: 52px;
272
+ height: 52px;
273
  background: var(--surface-3);
274
  border: 1px solid rgba(255, 255, 255, 0.1);
275
+ border-radius: var(--radious-m);
276
  color: var(--content-mid);
277
  font-family: var(--font);
278
+ font-size: 28px;
279
  font-weight: 400;
280
  cursor: pointer;
281
  display: flex;
 
296
  background: var(--surface-2);
297
  border-left: var(--stroke);
298
  padding: var(--sp-s);
299
+ overflow-x: hidden;
300
  overflow-y: auto;
301
+ overscroll-behavior: contain;
302
  height: 100%;
303
  min-height: 0;
304
+ max-height: 100%;
305
  display: flex;
306
  flex-direction: column;
307
  gap: var(--sp-s);
308
  position: relative;
309
  z-index: 1;
310
+ -webkit-overflow-scrolling: touch;
 
311
  }
312
 
313
  /* ── Sections ────────────────────────────────────────── */
314
 
315
+ /* Keep natural height so the panel scrolls instead of squishing controls. */
316
+ #panel > .section {
317
+ flex-shrink: 0;
318
+ }
319
+
320
  .section {
321
  background: var(--surface-3);
322
  border-radius: var(--radious-m);
 
324
  }
325
 
326
  .section-title {
327
+ font-size: 14px;
328
  font-weight: 500;
329
  letter-spacing: 0;
330
  text-transform: uppercase;
 
351
  }
352
 
353
  .stat .lbl {
354
+ font-size: 14px;
355
  font-weight: 500;
356
  letter-spacing: 0;
357
  text-transform: uppercase;
 
360
  }
361
 
362
  .stat .val {
363
+ font-size: 23px;
364
  font-weight: 400;
365
  color: var(--content-hi);
366
  line-height: 1.2;
 
377
  background: var(--surface-2);
378
  }
379
 
380
+ .st-sm { font-size: 18px; }
 
 
 
 
381
 
382
  .stat-sub {
383
+ font-size: 16px;
384
  font-weight: 400;
385
  color: var(--content-lo);
386
  margin-top: 2px;
 
394
  }
395
 
396
  .st-risk-val {
397
+ font-size: 18px;
398
+ min-width: 50px;
399
  }
400
 
401
  .risk-track {
 
427
  .ctrl label {
428
  display: flex;
429
  justify-content: space-between;
430
+ font-size: 14px;
431
  font-weight: 500;
432
  color: var(--content-lo);
433
  margin-bottom: var(--sp-xs);
 
436
 
437
  .ctrl label span {
438
  color: var(--content-hi);
439
+ font-size: 16px;
440
  font-weight: 400;
441
  }
442
 
 
471
  border: none;
472
  }
473
 
474
+ /* Camera presets */
475
+ .preset-select {
476
+ width: 100%;
477
+ height: 42px;
478
+ padding: 0 var(--sp-m);
479
+ background-color: var(--surface-4);
480
+ border: 1px solid rgba(255, 255, 255, 0.1);
481
+ border-radius: var(--radious-s);
482
+ color: var(--content-hi);
483
+ font-family: var(--font);
484
+ font-size: 14px;
485
+ font-weight: 500;
486
+ cursor: pointer;
487
+ outline: none;
488
+ }
489
+
490
+ .preset-select:hover {
491
+ border-color: rgba(255, 255, 255, 0.16);
492
+ }
493
+
494
+ .preset-select:focus {
495
+ border-color: var(--primary);
496
+ }
497
+
498
+ .coastal-surveillance-sim[data-theme="light"] .preset-select {
499
+ border-color: rgba(0, 0, 0, 0.1);
500
+ }
501
+
502
+ .coastal-surveillance-sim[data-theme="light"] .preset-select:hover {
503
+ border-color: rgba(0, 0, 0, 0.18);
504
+ }
505
+
506
  /* Direction toggle (panel only; no on-map indicator) */
507
  .dir-toggle {
508
  display: flex;
 
512
 
513
  .dir-btn {
514
  flex: 1;
515
+ height: 46px;
516
  background: var(--surface-4);
517
  border: 1px solid rgba(255, 255, 255, 0.1);
518
  border-radius: var(--radious-s);
519
  color: var(--content-lo);
520
  font-family: var(--font);
521
+ font-size: 16px;
522
  font-weight: 500;
523
  letter-spacing: 0.015em;
524
  cursor: pointer;
 
540
 
541
  /* Hint */
542
  .hint {
543
+ font-size: 14px;
544
  font-weight: 400;
545
  color: var(--content-lo);
546
  line-height: 1.5;
 
555
  @media (max-width: 700px) {
556
  .coastal-surveillance-sim {
557
  grid-template-columns: 1fr;
558
+ grid-template-rows: 46px minmax(0, 55vw) minmax(0, 1fr);
559
  }
560
  #panel {
561
  border-left: none;