kevinconka commited on
Commit
c40323b
·
1 Parent(s): b8f1049

Implement Coastal Surveillance Simulator with HTML, CSS, and JavaScript. Added interactive canvas for simulation, control panel for camera and boat settings, and responsive design for better usability.

Browse files
Files changed (3) hide show
  1. app.js +638 -0
  2. index.html +145 -19
  3. style.css +381 -18
app.js ADDED
@@ -0,0 +1,638 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ;(function () {
2
+ 'use strict'
3
+
4
+ function $(id) {
5
+ return document.getElementById(id)
6
+ }
7
+
8
+ var canvas = null
9
+ var wrap = null
10
+ var ctx = null
11
+ var W = 0
12
+ var H = 0
13
+ var camCX = 0
14
+ var camCY = 0
15
+ var SCALE = 0
16
+ var zoomLevel = 1.0
17
+
18
+ var boatStart = { x: 0.0, y: 1.0 }
19
+ var boatEnd = { x: 0.0, y: 0.15 }
20
+ var dragging = null
21
+ var camHeading = 0
22
+ var boatT = 0
23
+ var boatDir = 1
24
+ var lastTime = null
25
+
26
+ var prevVisible = null
27
+ var detectedStreak = 0
28
+ var blindStreak = 0
29
+ var lastDetectedStreak = 0
30
+ var lastBlindStreak = 0
31
+ var prevDetectedStreak = 0
32
+ var prevBlindStreak = 0
33
+
34
+ var lastPinchDist = null
35
+ var rafId = 0
36
+ var running = false
37
+ var camDir = -1
38
+
39
+ function resetStreaks() {
40
+ detectedStreak = 0
41
+ blindStreak = 0
42
+ lastDetectedStreak = 0
43
+ lastBlindStreak = 0
44
+ prevDetectedStreak = 0
45
+ prevBlindStreak = 0
46
+ prevVisible = null
47
+ }
48
+
49
+ function resize() {
50
+ if (!wrap || !canvas) return
51
+ W = wrap.clientWidth
52
+ H = wrap.clientHeight
53
+ canvas.width = W
54
+ canvas.height = H
55
+ camCX = W / 2
56
+ camCY = H * 0.88
57
+ SCALE = Math.min(W, H) * 0.42 * zoomLevel
58
+ }
59
+
60
+ function updateZoomLabel() {
61
+ var el = $('zoom-label')
62
+ if (el) el.textContent = Math.round(zoomLevel * 100) + '%'
63
+ }
64
+
65
+ function applyZoom(delta) {
66
+ var factor = delta > 0 ? 1.12 : 1 / 1.12
67
+ zoomLevel = Math.max(0.3, Math.min(8, zoomLevel * factor))
68
+ SCALE = Math.min(W, H) * 0.42 * zoomLevel
69
+ updateZoomLabel()
70
+ }
71
+
72
+ function getParams() {
73
+ return {
74
+ fov: +$('input-fov').value,
75
+ frontSpeed: +$('input-front-speed').value,
76
+ backSpeed: +$('input-back-speed').value,
77
+ frontArc: +$('input-front-arc').value,
78
+ boatSpeed: +$('input-boat-speed').value,
79
+ }
80
+ }
81
+
82
+ function worldToCanvas(wx, wy) {
83
+ return { x: camCX + wx * SCALE, y: camCY - wy * SCALE }
84
+ }
85
+
86
+ function canvasToWorld(cx, cy) {
87
+ return { x: (cx - camCX) / SCALE, y: (camCY - cy) / SCALE }
88
+ }
89
+
90
+ function isFront(heading, arc) {
91
+ var h = ((heading % 360) + 360) % 360
92
+ if (h > 180) h -= 360
93
+ return Math.abs(h) <= arc / 2
94
+ }
95
+
96
+ function scanTime(p) {
97
+ return p.frontArc / p.frontSpeed + (360 - p.frontArc) / p.backSpeed
98
+ }
99
+
100
+ function setDir(d) {
101
+ camDir = d
102
+ var cw = $('dir-cw')
103
+ var ccw = $('dir-ccw')
104
+ if (cw) cw.classList.toggle('active', d === 1)
105
+ if (ccw) ccw.classList.toggle('active', d === -1)
106
+ }
107
+
108
+ function boatAngle(bx, by) {
109
+ return (Math.atan2(bx, by) * 180) / Math.PI
110
+ }
111
+
112
+ function isBoatVisible(bx, by, heading, fovVal) {
113
+ var rel = (((boatAngle(bx, by) - heading + 540) % 360) - 180)
114
+ return Math.abs(rel) <= fovVal / 2 && by > -0.05
115
+ }
116
+
117
+ function drawDirIndicator(d) {
118
+ if (!ctx) return
119
+ var cx = 30,
120
+ cy = 30,
121
+ r = 14
122
+ ctx.beginPath()
123
+ ctx.arc(cx, cy, r, 0, Math.PI * 2)
124
+ ctx.strokeStyle = 'rgba(74,158,255,0.2)'
125
+ ctx.lineWidth = 1
126
+ ctx.stroke()
127
+ var start = -Math.PI / 2
128
+ var end = start + (d > 0 ? 1.2 : -1.2)
129
+ ctx.beginPath()
130
+ ctx.arc(cx, cy, r - 3, start, end, d < 0)
131
+ ctx.strokeStyle = 'rgba(74,158,255,0.7)'
132
+ ctx.lineWidth = 2
133
+ ctx.stroke()
134
+ var tx = cx + Math.cos(end) * (r - 3)
135
+ var ty = cy + Math.sin(end) * (r - 3)
136
+ ctx.beginPath()
137
+ ctx.arc(tx, ty, 2, 0, Math.PI * 2)
138
+ ctx.fillStyle = 'rgba(74,158,255,0.7)'
139
+ ctx.fill()
140
+ ctx.fillStyle = 'rgba(74,158,255,0.55)'
141
+ ctx.font = "7px 'Courier New', monospace"
142
+ ctx.textAlign = 'center'
143
+ ctx.fillText(d > 0 ? 'CW' : 'CCW', cx, cy + 3)
144
+ }
145
+
146
+ function draw(p, bx, by, inFov) {
147
+ if (!ctx) return
148
+ ctx.clearRect(0, 0, W, H)
149
+
150
+ ctx.fillStyle = '#060d16'
151
+ ctx.fillRect(0, 0, W, H)
152
+ ctx.fillStyle = '#06100a'
153
+ ctx.fillRect(0, camCY, W, H - camCY)
154
+
155
+ ctx.beginPath()
156
+ ctx.moveTo(0, camCY)
157
+ ctx.lineTo(W, camCY)
158
+ ctx.strokeStyle = 'rgba(74,158,255,0.3)'
159
+ ctx.lineWidth = 1
160
+ ctx.setLineDash([6, 4])
161
+ ctx.stroke()
162
+ ctx.setLineDash([])
163
+
164
+ var niceIntervals = [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0]
165
+ var targetMinPx = 100
166
+ var targetMaxPx = 240
167
+ var ringInterval = niceIntervals[0]
168
+ for (var i = 0; i < niceIntervals.length; i++) {
169
+ var iv = niceIntervals[i]
170
+ var px = iv * SCALE
171
+ if (px >= targetMinPx && px <= targetMaxPx) {
172
+ ringInterval = iv
173
+ break
174
+ }
175
+ if (px < targetMinPx) ringInterval = iv
176
+ }
177
+ var maxDist = Math.sqrt(Math.pow(Math.max(camCX, W - camCX), 2) + Math.pow(camCY, 2))
178
+ var maxRingCount = Math.ceil(maxDist / (ringInterval * SCALE)) + 1
179
+ function fmtDist(d) {
180
+ return d < 1 ? Math.round(d * 1000) + 'm' : d.toFixed(d < 10 ? 2 : 1).replace(/\.0+$/, '') + 'km'
181
+ }
182
+
183
+ for (var ri = 1; ri <= maxRingCount; ri++) {
184
+ var r = ri * ringInterval * SCALE
185
+ ctx.beginPath()
186
+ ctx.arc(camCX, camCY, r, 0, Math.PI * 2)
187
+ ctx.strokeStyle = 'rgba(74,158,255,0.10)'
188
+ ctx.lineWidth = 0.5
189
+ ctx.stroke()
190
+ ctx.beginPath()
191
+ ctx.moveTo(camCX - 5, camCY - r)
192
+ ctx.lineTo(camCX + 5, camCY - r)
193
+ ctx.strokeStyle = 'rgba(74,158,255,0.5)'
194
+ ctx.lineWidth = 1
195
+ ctx.stroke()
196
+ ctx.fillStyle = 'rgba(74,158,255,0.55)'
197
+ ctx.font = "10px 'Courier New', monospace"
198
+ ctx.textAlign = 'left'
199
+ ctx.fillText(fmtDist(ri * ringInterval), camCX + 8, camCY - r + 4)
200
+ }
201
+
202
+ var compassR = Math.min(camCX * 0.88, camCY * 0.92)
203
+ var canvasBase = -Math.PI / 2
204
+ for (var deg = 0; deg < 360; deg += 10) {
205
+ var a = canvasBase + (deg * Math.PI) / 180
206
+ var isMajor = deg % 30 === 0
207
+ var r0 = compassR
208
+ var r1 = compassR + (isMajor ? 8 : 4)
209
+ ctx.beginPath()
210
+ ctx.moveTo(camCX + Math.cos(a) * r0, camCY + Math.sin(a) * r0)
211
+ ctx.lineTo(camCX + Math.cos(a) * r1, camCY + Math.sin(a) * r1)
212
+ ctx.strokeStyle = isMajor ? 'rgba(74,158,255,0.5)' : 'rgba(74,158,255,0.2)'
213
+ ctx.lineWidth = isMajor ? 1 : 0.5
214
+ ctx.stroke()
215
+ if (isMajor) {
216
+ var lx = camCX + Math.cos(a) * (r1 + 10)
217
+ var ly = camCY + Math.sin(a) * (r1 + 10)
218
+ ctx.fillStyle = 'rgba(74,158,255,0.4)'
219
+ ctx.font = "8px 'Courier New', monospace"
220
+ ctx.textAlign = 'center'
221
+ ctx.textBaseline = 'middle'
222
+ ctx.fillText(deg + '°', lx, ly)
223
+ ctx.textBaseline = 'alphabetic'
224
+ }
225
+ }
226
+
227
+ ctx.beginPath()
228
+ ctx.moveTo(camCX, camCY)
229
+ ctx.lineTo(camCX, 0)
230
+ ctx.strokeStyle = 'rgba(74,158,255,0.12)'
231
+ ctx.lineWidth = 0.5
232
+ ctx.setLineDash([4, 4])
233
+ ctx.stroke()
234
+ ctx.setLineDash([])
235
+
236
+ var frontHalf = p.frontArc / 2
237
+ var frontA1 = canvasBase - (frontHalf * Math.PI) / 180
238
+ var frontA2 = canvasBase + (frontHalf * Math.PI) / 180
239
+ var INF = Math.max(W, H) * 3
240
+ ctx.save()
241
+ ctx.beginPath()
242
+ ctx.moveTo(camCX, camCY)
243
+ ctx.lineTo(camCX + Math.cos(frontA1) * INF, camCY + Math.sin(frontA1) * INF)
244
+ ctx.arc(camCX, camCY, INF, frontA1, frontA2)
245
+ ctx.closePath()
246
+ ctx.fillStyle = 'rgba(74,158,255,0.03)'
247
+ ctx.fill()
248
+ ctx.beginPath()
249
+ ctx.moveTo(camCX, camCY)
250
+ ctx.lineTo(camCX + Math.cos(frontA2) * INF, camCY + Math.sin(frontA2) * INF)
251
+ ctx.arc(camCX, camCY, INF, frontA2, frontA1 + Math.PI * 2)
252
+ ctx.closePath()
253
+ ctx.fillStyle = 'rgba(255,74,74,0.03)'
254
+ ctx.fill()
255
+ ctx.restore()
256
+
257
+ for (var ai = 0; ai < 2; ai++) {
258
+ var aedge = ai === 0 ? frontA1 : frontA2
259
+ ctx.beginPath()
260
+ ctx.moveTo(camCX, camCY)
261
+ ctx.lineTo(camCX + Math.cos(aedge) * INF, camCY + Math.sin(aedge) * INF)
262
+ ctx.strokeStyle = 'rgba(74,158,255,0.3)'
263
+ ctx.lineWidth = 1
264
+ ctx.setLineDash([5, 5])
265
+ ctx.stroke()
266
+ ctx.setLineDash([])
267
+ }
268
+
269
+ var labelR = 80
270
+ ctx.font = "8px 'Courier New', monospace"
271
+ ctx.textAlign = 'center'
272
+ ctx.fillStyle = 'rgba(74,158,255,0.45)'
273
+ ctx.fillText('FRONT ' + p.frontArc + '°', camCX, camCY - labelR)
274
+ ctx.fillStyle = 'rgba(255,74,74,0.35)'
275
+ if (camCY + 18 < H - 2) ctx.fillText('BACK ' + (360 - p.frontArc) + '°', camCX, camCY + 18)
276
+ ctx.fillStyle = 'rgba(74,158,255,0.4)'
277
+ ctx.fillText('±' + frontHalf.toFixed(0) + '°', camCX + Math.cos(frontA1) * labelR, camCY + Math.sin(frontA1) * labelR)
278
+ ctx.fillText('±' + frontHalf.toFixed(0) + '°', camCX + Math.cos(frontA2) * labelR, camCY + Math.sin(frontA2) * labelR)
279
+
280
+ var camRad = (camHeading * Math.PI) / 180
281
+ var fovRad = (p.fov * Math.PI) / 180
282
+ var coneLen = Math.max(W, H) * 2
283
+ ctx.beginPath()
284
+ ctx.moveTo(camCX, camCY)
285
+ ctx.arc(camCX, camCY, coneLen, canvasBase + camRad - fovRad / 2, canvasBase + camRad + fovRad / 2)
286
+ ctx.closePath()
287
+ ctx.fillStyle = 'rgba(74,158,255,0.07)'
288
+ ctx.fill()
289
+ ctx.beginPath()
290
+ ctx.moveTo(camCX, camCY)
291
+ ctx.arc(camCX, camCY, coneLen, canvasBase + camRad - fovRad / 2, canvasBase + camRad + fovRad / 2)
292
+ ctx.closePath()
293
+ ctx.strokeStyle = 'rgba(74,158,255,0.6)'
294
+ ctx.lineWidth = 1
295
+ ctx.stroke()
296
+
297
+ ctx.beginPath()
298
+ ctx.moveTo(camCX, camCY)
299
+ ctx.lineTo(camCX + Math.cos(canvasBase + camRad) * 32, camCY + Math.sin(canvasBase + camRad) * 32)
300
+ ctx.strokeStyle = 'rgba(74,158,255,0.9)'
301
+ ctx.lineWidth = 1.5
302
+ ctx.stroke()
303
+
304
+ var bC = worldToCanvas(bx, by)
305
+ var bsC = worldToCanvas(boatStart.x, boatStart.y)
306
+ var beC = worldToCanvas(boatEnd.x, boatEnd.y)
307
+ ctx.beginPath()
308
+ ctx.moveTo(bsC.x, bsC.y)
309
+ ctx.lineTo(beC.x, beC.y)
310
+ ctx.strokeStyle = 'rgba(255,74,74,0.25)'
311
+ ctx.lineWidth = 1
312
+ ctx.setLineDash([4, 3])
313
+ ctx.stroke()
314
+ ctx.setLineDash([])
315
+
316
+ ctx.beginPath()
317
+ ctx.moveTo(camCX, camCY)
318
+ ctx.lineTo(bC.x, bC.y)
319
+ ctx.strokeStyle = 'rgba(255,74,74,0.12)'
320
+ ctx.lineWidth = 0.5
321
+ ctx.stroke()
322
+
323
+ if (inFov) {
324
+ ctx.beginPath()
325
+ ctx.arc(bC.x, bC.y, 14, 0, Math.PI * 2)
326
+ ctx.fillStyle = 'rgba(0,229,160,0.12)'
327
+ ctx.fill()
328
+ }
329
+
330
+ ctx.beginPath()
331
+ ctx.arc(bC.x, bC.y, 5, 0, Math.PI * 2)
332
+ ctx.fillStyle = inFov ? '#00e5a0' : '#ff4a4a'
333
+ ctx.fill()
334
+ ctx.strokeStyle = inFov ? 'rgba(0,229,160,0.5)' : 'rgba(255,74,74,0.4)'
335
+ ctx.lineWidth = 1.5
336
+ ctx.stroke()
337
+
338
+ ctx.font = "9px 'Courier New', monospace"
339
+ ctx.textAlign = 'left'
340
+ ctx.fillStyle = inFov ? 'rgba(0,229,160,0.7)' : 'rgba(255,100,100,0.6)'
341
+ ctx.fillText('TGT', bC.x + 8, bC.y - 4)
342
+
343
+ ctx.beginPath()
344
+ ctx.arc(bsC.x, bsC.y, 4, 0, Math.PI * 2)
345
+ ctx.fillStyle = '#ff4a4a'
346
+ ctx.fill()
347
+ ctx.beginPath()
348
+ ctx.arc(beC.x, beC.y, 4, 0, Math.PI * 2)
349
+ ctx.fillStyle = '#ff9944'
350
+ ctx.fill()
351
+ ctx.font = "8px 'Courier New', monospace"
352
+ ctx.fillStyle = 'rgba(255,74,74,0.5)'
353
+ ctx.textAlign = 'center'
354
+ ctx.fillText('S', bsC.x, bsC.y + 14)
355
+ ctx.fillStyle = 'rgba(255,153,68,0.5)'
356
+ ctx.fillText('E', beC.x, beC.y + 14)
357
+
358
+ var cr = 8
359
+ ctx.beginPath()
360
+ ctx.arc(camCX, camCY, cr, 0, Math.PI * 2)
361
+ ctx.fillStyle = '#4a9eff'
362
+ ctx.fill()
363
+ ctx.strokeStyle = 'rgba(74,158,255,0.5)'
364
+ ctx.lineWidth = 1.5
365
+ ctx.stroke()
366
+ var corners = [
367
+ [-14, -14],
368
+ [14, -14],
369
+ [14, 14],
370
+ [-14, 14],
371
+ ]
372
+ for (var ci = 0; ci < corners.length; ci++) {
373
+ var dx = corners[ci][0],
374
+ dy = corners[ci][1]
375
+ ctx.beginPath()
376
+ ctx.moveTo(camCX + dx * 0.4, camCY + dy * 0.4)
377
+ ctx.lineTo(camCX + dx, camCY + dy)
378
+ ctx.strokeStyle = 'rgba(74,158,255,0.5)'
379
+ ctx.lineWidth = 1
380
+ ctx.stroke()
381
+ }
382
+
383
+ drawDirIndicator(camDir)
384
+
385
+ var dist = Math.sqrt(bx * bx + by * by)
386
+ var h360 = ((camHeading % 360) + 360) % 360
387
+ var st = scanTime(p)
388
+
389
+ function setText(id, text) {
390
+ var el = $(id)
391
+ if (el) el.textContent = text
392
+ }
393
+
394
+ setText('st-scan', st.toFixed(1) + 's')
395
+ setText('st-angle', h360.toFixed(1) + '°')
396
+ setText('st-dist', (dist * 1000).toFixed(0) + 'm')
397
+ setText('hdr-scan', st.toFixed(1) + 's')
398
+ setText('hdr-angle', h360.toFixed(1) + '°')
399
+
400
+ var travelEl = $('st-travel')
401
+ var blindEl = $('st-blind')
402
+ var travelPrevEl = $('st-travel-prev')
403
+ var blindPrevEl = $('st-blind-prev')
404
+ var riskEl = $('st-risk')
405
+ var riskBarEl = $('st-risk-bar')
406
+ if (travelEl) {
407
+ travelEl.textContent = (inFov ? detectedStreak : lastDetectedStreak).toFixed(1) + 'm'
408
+ travelEl.style.opacity = inFov ? '1' : '0.5'
409
+ }
410
+ if (blindEl) {
411
+ blindEl.textContent = (!inFov ? blindStreak : lastBlindStreak).toFixed(1) + 'm'
412
+ blindEl.style.opacity = !inFov ? '1' : '0.5'
413
+ }
414
+ if (travelPrevEl)
415
+ travelPrevEl.textContent =
416
+ prevDetectedStreak > 0 ? 'prev: ' + prevDetectedStreak.toFixed(1) + 'm' : 'prev: —'
417
+ if (blindPrevEl)
418
+ blindPrevEl.textContent =
419
+ prevBlindStreak > 0 ? 'prev: ' + prevBlindStreak.toFixed(1) + 'm' : 'prev: —'
420
+ var total = prevDetectedStreak + prevBlindStreak
421
+ if (riskEl && riskBarEl) {
422
+ if (total > 0) {
423
+ var risk = (prevBlindStreak / total) * 100
424
+ riskEl.textContent = risk.toFixed(0) + '%'
425
+ riskEl.style.color = risk > 66 ? '#ff4a4a' : risk > 33 ? '#ffcc44' : '#00e5a0'
426
+ riskBarEl.style.width = risk + '%'
427
+ riskBarEl.style.background = risk > 66 ? '#ff4a4a' : risk > 33 ? '#ffcc44' : '#00e5a0'
428
+ } else {
429
+ riskEl.textContent = '—'
430
+ riskBarEl.style.width = '0%'
431
+ }
432
+ }
433
+ var visEl = $('st-vis')
434
+ if (visEl) {
435
+ visEl.textContent = inFov ? 'YES' : 'NO'
436
+ visEl.className = 'val ' + (inFov ? 'active' : 'inactive')
437
+ }
438
+ }
439
+
440
+ function animate(ts) {
441
+ if (!running || !ctx) return
442
+ if (lastTime === null) lastTime = ts
443
+ var dt = Math.min((ts - lastTime) / 1000, 0.1)
444
+ lastTime = ts
445
+
446
+ var p = getParams()
447
+
448
+ var boatKmS = p.boatSpeed * 0.000514
449
+ var pathLen = Math.hypot(boatEnd.x - boatStart.x, boatEnd.y - boatStart.y)
450
+ if (pathLen > 0.001) {
451
+ var dtT = (boatKmS * dt) / pathLen
452
+ boatT += boatDir * dtT
453
+ if (boatT >= 1) {
454
+ boatT = 1
455
+ boatDir = -1
456
+ }
457
+ if (boatT <= 0) {
458
+ boatT = 0
459
+ boatDir = 1
460
+ }
461
+ }
462
+
463
+ var front = isFront(camHeading, p.frontArc)
464
+ var speed = front ? p.frontSpeed : p.backSpeed
465
+ camHeading += camDir * speed * dt
466
+ if (camHeading >= 180) camHeading -= 360
467
+ if (camHeading < -180) camHeading += 360
468
+
469
+ var bx = boatStart.x + (boatEnd.x - boatStart.x) * boatT
470
+ var by = boatStart.y + (boatEnd.y - boatStart.y) * boatT
471
+ var visible = isBoatVisible(bx, by, camHeading, p.fov)
472
+
473
+ var distThisFrame = p.boatSpeed * 0.514 * dt
474
+ if (prevVisible !== null && visible !== prevVisible) {
475
+ if (visible) {
476
+ prevBlindStreak = blindStreak
477
+ lastBlindStreak = blindStreak
478
+ blindStreak = 0
479
+ } else {
480
+ prevDetectedStreak = detectedStreak
481
+ lastDetectedStreak = detectedStreak
482
+ detectedStreak = 0
483
+ }
484
+ }
485
+ if (visible) detectedStreak += distThisFrame
486
+ else blindStreak += distThisFrame
487
+ prevVisible = visible
488
+
489
+ draw(p, bx, by, visible)
490
+ if (running) rafId = requestAnimationFrame(animate)
491
+ }
492
+
493
+ function getCanvasXY(e) {
494
+ if (!canvas) return { x: 0, y: 0 }
495
+ var rect = canvas.getBoundingClientRect()
496
+ var sx = W / rect.width
497
+ var sy = H / rect.height
498
+ var src = 'touches' in e && e.touches.length ? e.touches[0] : e
499
+ return { x: (src.clientX - rect.left) * sx, y: (src.clientY - rect.top) * sy }
500
+ }
501
+
502
+ function onResize() {
503
+ resize()
504
+ }
505
+
506
+ function onMouseDown(e) {
507
+ if (!canvas) return
508
+ var pt = getCanvasXY(e)
509
+ var bsC = worldToCanvas(boatStart.x, boatStart.y)
510
+ var beC = worldToCanvas(boatEnd.x, boatEnd.y)
511
+ if (Math.hypot(pt.x - bsC.x, pt.y - bsC.y) < 14) dragging = 'start'
512
+ else if (Math.hypot(pt.x - beC.x, pt.y - beC.y) < 14) dragging = 'end'
513
+ }
514
+
515
+ function onMouseMove(e) {
516
+ if (!dragging) return
517
+ var pt = getCanvasXY(e)
518
+ var w = canvasToWorld(pt.x, pt.y)
519
+ w.y = Math.max(0.05, Math.min(1.3, w.y))
520
+ w.x = Math.max(-1.4, Math.min(1.4, w.x))
521
+ if (dragging === 'start') boatStart = w
522
+ else boatEnd = w
523
+ }
524
+
525
+ function onMouseUp() {
526
+ dragging = null
527
+ }
528
+
529
+ function onTouchStart(e) {
530
+ if (e.touches.length === 2) {
531
+ lastPinchDist = Math.hypot(
532
+ e.touches[0].clientX - e.touches[1].clientX,
533
+ e.touches[0].clientY - e.touches[1].clientY,
534
+ )
535
+ dragging = null
536
+ return
537
+ }
538
+ if (e.touches.length !== 1) return
539
+ e.preventDefault()
540
+ var pt = getCanvasXY(e)
541
+ var bsC = worldToCanvas(boatStart.x, boatStart.y)
542
+ var beC = worldToCanvas(boatEnd.x, boatEnd.y)
543
+ if (Math.hypot(pt.x - bsC.x, pt.y - bsC.y) < 18) dragging = 'start'
544
+ else if (Math.hypot(pt.x - beC.x, pt.y - beC.y) < 18) dragging = 'end'
545
+ }
546
+
547
+ function onTouchMove(e) {
548
+ if (e.touches.length === 2 && lastPinchDist !== null) {
549
+ e.preventDefault()
550
+ var dist = Math.hypot(
551
+ e.touches[0].clientX - e.touches[1].clientX,
552
+ e.touches[0].clientY - e.touches[1].clientY,
553
+ )
554
+ var delta = dist - lastPinchDist
555
+ applyZoom(delta)
556
+ lastPinchDist = dist
557
+ return
558
+ }
559
+ if (!dragging) return
560
+ e.preventDefault()
561
+ var pt = getCanvasXY(e)
562
+ var w = canvasToWorld(pt.x, pt.y)
563
+ w.y = Math.max(0.05, Math.min(1.3, w.y))
564
+ w.x = Math.max(-1.4, Math.min(1.4, w.x))
565
+ if (dragging === 'start') boatStart = w
566
+ else boatEnd = w
567
+ }
568
+
569
+ function onTouchEnd(e) {
570
+ dragging = null
571
+ if (e.touches.length < 2) lastPinchDist = null
572
+ }
573
+
574
+ function onWheel(e) {
575
+ e.preventDefault()
576
+ applyZoom(-e.deltaY)
577
+ }
578
+
579
+ function bindRange(id, valSpanId) {
580
+ var input = $(id)
581
+ var span = valSpanId ? $(valSpanId) : null
582
+ function sync() {
583
+ if (span) span.textContent = input.value
584
+ }
585
+ input.addEventListener('input', function () {
586
+ sync()
587
+ resetStreaks()
588
+ })
589
+ sync()
590
+ }
591
+
592
+ function init() {
593
+ canvas = $('c')
594
+ wrap = $('canvas-wrap')
595
+ if (!canvas || !wrap) return
596
+ ctx = canvas.getContext('2d')
597
+ if (!ctx) return
598
+
599
+ running = true
600
+ resize()
601
+ updateZoomLabel()
602
+ setDir(-1)
603
+ window.addEventListener('resize', onResize)
604
+
605
+ canvas.addEventListener('mousedown', onMouseDown)
606
+ canvas.addEventListener('mousemove', onMouseMove)
607
+ canvas.addEventListener('mouseup', onMouseUp)
608
+ canvas.addEventListener('touchstart', onTouchStart, { passive: false })
609
+ canvas.addEventListener('touchmove', onTouchMove, { passive: false })
610
+ canvas.addEventListener('touchend', onTouchEnd)
611
+ canvas.addEventListener('touchcancel', onTouchEnd)
612
+ canvas.addEventListener('wheel', onWheel, { passive: false })
613
+
614
+ $('zoom-out').addEventListener('click', function () {
615
+ applyZoom(-1)
616
+ })
617
+ $('zoom-in').addEventListener('click', function () {
618
+ applyZoom(1)
619
+ })
620
+ $('dir-cw').addEventListener('click', function () {
621
+ setDir(1)
622
+ })
623
+ $('dir-ccw').addEventListener('click', function () {
624
+ setDir(-1)
625
+ })
626
+
627
+ bindRange('input-fov', 'val-fov')
628
+ bindRange('input-front-speed', 'val-front-speed')
629
+ bindRange('input-back-speed', 'val-back-speed')
630
+ bindRange('input-front-arc', 'val-front-arc')
631
+ bindRange('input-boat-speed', 'val-boat-speed')
632
+
633
+ rafId = requestAnimationFrame(animate)
634
+ }
635
+
636
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init)
637
+ else init()
638
+ })()
index.html CHANGED
@@ -1,19 +1,145 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Coastal Surveillance Simulator</title>
8
+ <link rel="stylesheet" href="styles.css" />
9
+ </head>
10
+
11
+ <body>
12
+ <div id="app">
13
+ <div class="coastal-surveillance-sim">
14
+ <div id="header">
15
+ <span class="blink" aria-hidden="true"></span>
16
+ <h1>Coastal Surveillance Sim</h1>
17
+ <span class="sub">// PTZ camera model</span>
18
+ <div class="spacer"></div>
19
+ <div class="hstat">SCAN <span id="hdr-scan">—</span></div>
20
+ <div class="hstat" style="margin-left: 12px">HEADING <span id="hdr-angle">—</span></div>
21
+ </div>
22
+
23
+ <div id="canvas-wrap">
24
+ <canvas id="c" width="800" height="600"></canvas>
25
+ <div class="zoom-bar">
26
+ <button type="button" class="zoom-btn" id="zoom-out" aria-label="Zoom out">−</button>
27
+ <span id="zoom-label" class="zoom-label">100%</span>
28
+ <button type="button" class="zoom-btn" id="zoom-in" aria-label="Zoom in">+</button>
29
+ </div>
30
+ </div>
31
+
32
+ <div id="panel">
33
+ <div class="section">
34
+ <div class="section-title">Status</div>
35
+ <div class="stats-grid">
36
+ <div class="stat">
37
+ <div class="lbl">Scan time</div>
38
+ <div id="st-scan" class="val">—</div>
39
+ </div>
40
+ <div class="stat">
41
+ <div class="lbl">Cam heading</div>
42
+ <div id="st-angle" class="val">—</div>
43
+ </div>
44
+ <div class="stat">
45
+ <div class="lbl">Boat dist.</div>
46
+ <div id="st-dist" class="val">—</div>
47
+ </div>
48
+ <div class="stat">
49
+ <div class="lbl">Detected</div>
50
+ <div id="st-vis" class="val">—</div>
51
+ </div>
52
+ </div>
53
+ <div class="stats-extra">
54
+ <div class="stat">
55
+ <div class="lbl">Detected dist.</div>
56
+ <div id="st-travel" class="val st-sm">—</div>
57
+ <div id="st-travel-prev" class="stat-sub">prev: —</div>
58
+ </div>
59
+ <div class="stat">
60
+ <div class="lbl">Blind dist.</div>
61
+ <div id="st-blind" class="val st-sm">—</div>
62
+ <div id="st-blind-prev" class="stat-sub">prev: —</div>
63
+ </div>
64
+ <div class="stat stat-risk">
65
+ <div class="lbl">Blind risk</div>
66
+ <div class="risk-row">
67
+ <div id="st-risk" class="val st-risk-val">—</div>
68
+ <div class="risk-track">
69
+ <div id="st-risk-bar" class="risk-fill"></div>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ </div>
74
+ </div>
75
+
76
+ <div class="section">
77
+ <div class="section-title">Camera</div>
78
+ <div class="section-body">
79
+ <div class="ctrl">
80
+ <label for="input-fov">FOV <span id="val-fov">18</span>°</label>
81
+ <input id="input-fov" type="range" min="5" max="90" step="1" value="18" />
82
+ </div>
83
+ <div class="ctrl">
84
+ <label for="input-front-speed">Front speed <span id="val-front-speed">6</span>°/s</label>
85
+ <input id="input-front-speed" type="range" min="1" max="30" step="1" value="6" />
86
+ </div>
87
+ <div class="ctrl">
88
+ <label for="input-back-speed">Back speed <span id="val-back-speed">60</span>°/s</label>
89
+ <input id="input-back-speed" type="range" min="5" max="180" step="5" value="60" />
90
+ </div>
91
+ <div class="ctrl">
92
+ <label for="input-front-arc">Front arc <span id="val-front-arc">180</span>°</label>
93
+ <input id="input-front-arc" type="range" min="10" max="350" step="5" value="180" />
94
+ </div>
95
+ <div class="ctrl">
96
+ <label>Rotation</label>
97
+ <div class="dir-toggle">
98
+ <button type="button" class="dir-btn" id="dir-cw">↻ CW</button>
99
+ <button type="button" class="dir-btn" id="dir-ccw">↺ CCW</button>
100
+ </div>
101
+ </div>
102
+ </div>
103
+ </div>
104
+
105
+ <div class="section">
106
+ <div class="section-title">Boat</div>
107
+ <div class="section-body">
108
+ <div class="ctrl">
109
+ <label for="input-boat-speed">Speed <span id="val-boat-speed">20</span> kt</label>
110
+ <input id="input-boat-speed" type="range" min="1" max="60" step="1" value="20" />
111
+ </div>
112
+ <div class="hint">
113
+ Drag <span class="hint-s">S</span> or <span class="hint-e">E</span> anchors on canvas to reposition
114
+ trajectory.
115
+ </div>
116
+ </div>
117
+ </div>
118
+
119
+ <div class="section">
120
+ <div class="section-title">Legend</div>
121
+ <div class="section-body">
122
+ <div class="legend">
123
+ <div class="leg-item">
124
+ <span class="leg-dot leg-dot--cam"></span> Camera &amp; FOV
125
+ </div>
126
+ <div class="leg-item">
127
+ <span class="leg-dot leg-dot--boat"></span> Boat (undetected)
128
+ </div>
129
+ <div class="leg-item">
130
+ <span class="leg-dot leg-dot--det"></span> Boat (detected)
131
+ </div>
132
+ <div class="leg-item"><span class="leg-line leg-line--rings"></span> Range rings</div>
133
+ <div class="leg-item">
134
+ <span class="leg-line leg-line--arc"></span> Front arc boundary
135
+ </div>
136
+ </div>
137
+ </div>
138
+ </div>
139
+ </div>
140
+ </div>
141
+ </div>
142
+ <script src="app.js" defer></script>
143
+ </body>
144
+
145
+ </html>
style.css CHANGED
@@ -1,28 +1,391 @@
 
 
 
 
 
 
 
1
  body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  }
5
 
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
 
 
 
 
 
 
9
  }
10
 
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
 
 
 
 
 
 
 
 
 
 
 
16
  }
17
 
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  }
25
 
26
- .card p:last-child {
27
- margin-bottom: 0;
 
 
 
 
 
 
 
28
  }
 
1
+ html,
2
+ body,
3
+ #app {
4
+ height: 100%;
5
+ margin: 0;
6
+ }
7
+
8
  body {
9
+ overflow: hidden;
10
+ }
11
+
12
+ .coastal-surveillance-sim {
13
+ --bg0: #080e14;
14
+ --bg1: #0c1520;
15
+ --bg2: #111d2c;
16
+ --bg3: #162030;
17
+ --accent: #4a9eff;
18
+ --accent-dim: rgba(74, 158, 255, 0.18);
19
+ --accent-glow: rgba(74, 158, 255, 0.08);
20
+ --text: #c8daf0;
21
+ --text-muted: #4a6a8a;
22
+ --text-dim: #2a4a6a;
23
+ --border: rgba(74, 158, 255, 0.15);
24
+ --border-bright: rgba(74, 158, 255, 0.35);
25
+ --danger: #ff4a4a;
26
+ --success: #00e5a0;
27
+ --warn: #ffcc44;
28
+ --font: 'Courier New', monospace;
29
+
30
+ box-sizing: border-box;
31
+ width: 100%;
32
+ min-height: 100%;
33
+ height: 100%;
34
+ background: var(--bg0);
35
+ color: var(--text);
36
+ font-family: var(--font);
37
+ font-size: 13px;
38
+ overflow: hidden;
39
+ display: grid;
40
+ grid-template-columns: 1fr 252px;
41
+ grid-template-rows: 38px 1fr;
42
+ }
43
+
44
+ .coastal-surveillance-sim *,
45
+ .coastal-surveillance-sim *::before,
46
+ .coastal-surveillance-sim *::after {
47
+ box-sizing: border-box;
48
+ }
49
+
50
+ #header {
51
+ grid-column: 1 / -1;
52
+ padding: 0 16px;
53
+ border-bottom: 1px solid var(--border);
54
+ background: var(--bg1);
55
+ display: flex;
56
+ align-items: center;
57
+ gap: 16px;
58
+ }
59
+ #header h1 {
60
+ font-size: 11px;
61
+ font-weight: normal;
62
+ letter-spacing: 0.18em;
63
+ color: var(--accent);
64
+ text-transform: uppercase;
65
+ }
66
+ #header .sub {
67
+ font-size: 10px;
68
+ color: var(--text-muted);
69
+ letter-spacing: 0.08em;
70
+ }
71
+ #header .spacer {
72
+ flex: 1;
73
+ }
74
+ #header .hstat {
75
+ font-size: 10px;
76
+ letter-spacing: 0.1em;
77
+ color: var(--text-muted);
78
+ display: flex;
79
+ align-items: center;
80
+ gap: 5px;
81
+ }
82
+ #header .hstat span {
83
+ color: var(--accent);
84
+ }
85
+ .blink {
86
+ display: inline-block;
87
+ width: 6px;
88
+ height: 6px;
89
+ border-radius: 50%;
90
+ background: var(--success);
91
+ animation: blink 1.4s ease-in-out infinite;
92
+ }
93
+ @keyframes blink {
94
+ 0%,
95
+ 100% {
96
+ opacity: 1;
97
+ }
98
+ 50% {
99
+ opacity: 0.15;
100
+ }
101
  }
102
 
103
+ #canvas-wrap {
104
+ position: relative;
105
+ background: var(--bg0);
106
+ overflow: hidden;
107
+ }
108
+ #canvas-wrap canvas {
109
+ display: block;
110
+ width: 100%;
111
+ height: 100%;
112
  }
113
 
114
+ .zoom-bar {
115
+ position: absolute;
116
+ bottom: 10px;
117
+ right: 10px;
118
+ display: flex;
119
+ align-items: center;
120
+ gap: 4px;
121
+ z-index: 10;
122
+ }
123
+ .zoom-label {
124
+ font-family: var(--font);
125
+ font-size: 10px;
126
+ color: var(--accent);
127
+ min-width: 34px;
128
+ text-align: center;
129
+ letter-spacing: 0.08em;
130
  }
131
 
132
+ #panel {
133
+ background: var(--bg1);
134
+ border-left: 1px solid var(--border);
135
+ padding: 10px;
136
+ overflow-y: auto;
137
+ display: flex;
138
+ flex-direction: column;
139
+ gap: 8px;
140
+ }
141
+
142
+ .section {
143
+ border: 1px solid var(--border);
144
+ background: var(--bg2);
145
+ }
146
+ .section-title {
147
+ font-size: 9px;
148
+ letter-spacing: 0.2em;
149
+ text-transform: uppercase;
150
+ color: var(--accent);
151
+ padding: 5px 8px;
152
+ background: rgba(74, 158, 255, 0.05);
153
+ border-bottom: 1px solid var(--border);
154
+ }
155
+ .section-body {
156
+ padding: 8px;
157
+ }
158
+
159
+ .stats-grid {
160
+ display: grid;
161
+ grid-template-columns: 1fr 1fr;
162
+ gap: 1px;
163
+ background: var(--border);
164
+ }
165
+ .stat {
166
+ background: var(--bg2);
167
+ padding: 6px 8px;
168
+ }
169
+ .stat .lbl {
170
+ font-size: 8px;
171
+ letter-spacing: 0.14em;
172
+ text-transform: uppercase;
173
+ color: var(--text-muted);
174
+ margin-bottom: 2px;
175
+ }
176
+ .stat .val {
177
+ font-size: 15px;
178
+ color: var(--text);
179
+ letter-spacing: 0.02em;
180
+ }
181
+ .stat .val.active {
182
+ color: var(--success);
183
+ }
184
+ .stat .val.inactive {
185
+ color: var(--danger);
186
+ }
187
+
188
+ .stats-extra {
189
+ margin-top: 1px;
190
+ display: grid;
191
+ grid-template-columns: 1fr 1fr;
192
+ gap: 1px;
193
+ background: var(--border);
194
+ }
195
+ .stats-extra .stat-risk {
196
+ grid-column: 1 / -1;
197
+ border-top: 1px solid rgba(255, 74, 74, 0.2);
198
+ }
199
+ .st-sm {
200
+ font-size: 13px;
201
+ }
202
+ .stat-sub {
203
+ font-size: 9px;
204
+ color: var(--text-muted);
205
+ opacity: 0.5;
206
+ margin-top: 2px;
207
+ letter-spacing: 0.06em;
208
+ }
209
+ .risk-row {
210
+ display: flex;
211
+ align-items: center;
212
+ gap: 8px;
213
+ margin-top: 3px;
214
+ }
215
+ .st-risk-val {
216
+ font-size: 14px;
217
+ min-width: 38px;
218
+ }
219
+ .risk-track {
220
+ flex: 1;
221
+ height: 3px;
222
+ background: rgba(255, 255, 255, 0.06);
223
+ overflow: hidden;
224
+ }
225
+ .risk-fill {
226
+ height: 100%;
227
+ width: 0%;
228
+ background: #ff4a4a;
229
+ transition: width 0.4s;
230
+ }
231
+
232
+ .ctrl {
233
+ margin-bottom: 8px;
234
+ }
235
+ .ctrl:last-child {
236
+ margin-bottom: 0;
237
+ }
238
+ .ctrl label {
239
+ display: flex;
240
+ justify-content: space-between;
241
+ font-size: 10px;
242
+ color: var(--text-muted);
243
+ margin-bottom: 3px;
244
+ letter-spacing: 0.06em;
245
+ text-transform: uppercase;
246
+ }
247
+ .ctrl label span {
248
+ color: var(--text);
249
+ font-size: 11px;
250
+ }
251
+
252
+ input[type='range'] {
253
+ width: 100%;
254
+ -webkit-appearance: none;
255
+ height: 2px;
256
+ background: rgba(74, 158, 255, 0.15);
257
+ outline: none;
258
+ cursor: pointer;
259
+ }
260
+ input[type='range']::-webkit-slider-thumb {
261
+ -webkit-appearance: none;
262
+ width: 10px;
263
+ height: 10px;
264
+ background: var(--accent);
265
+ cursor: pointer;
266
+ border-radius: 50%;
267
+ box-shadow: 0 0 4px rgba(74, 158, 255, 0.6);
268
+ }
269
+ input[type='range']::-moz-range-thumb {
270
+ width: 10px;
271
+ height: 10px;
272
+ background: var(--accent);
273
+ cursor: pointer;
274
+ border-radius: 50%;
275
+ border: none;
276
+ }
277
+
278
+ .dir-toggle {
279
+ display: flex;
280
+ gap: 4px;
281
+ margin-top: 4px;
282
+ }
283
+ .dir-btn {
284
+ flex: 1;
285
+ padding: 5px 4px;
286
+ background: var(--bg3);
287
+ border: 1px solid var(--border);
288
+ color: var(--text-muted);
289
+ font-family: var(--font);
290
+ font-size: 10px;
291
+ letter-spacing: 0.1em;
292
+ cursor: pointer;
293
+ text-align: center;
294
+ text-transform: uppercase;
295
+ transition: all 0.12s;
296
+ }
297
+ .dir-btn:hover {
298
+ border-color: var(--accent);
299
+ color: var(--accent);
300
+ }
301
+ .dir-btn.active {
302
+ background: var(--accent-dim);
303
+ border-color: var(--accent);
304
+ color: var(--accent);
305
+ }
306
+
307
+ .hint {
308
+ font-size: 9px;
309
+ color: var(--text-muted);
310
+ letter-spacing: 0.04em;
311
+ line-height: 1.7;
312
+ opacity: 0.7;
313
+ }
314
+ .hint-s {
315
+ color: #ff6b6b;
316
+ }
317
+ .hint-e {
318
+ color: #ff9944;
319
+ }
320
+
321
+ .legend {
322
+ display: flex;
323
+ flex-direction: column;
324
+ gap: 4px;
325
+ }
326
+ .leg-item {
327
+ display: flex;
328
+ align-items: center;
329
+ gap: 7px;
330
+ font-size: 10px;
331
+ color: var(--text-muted);
332
+ }
333
+ .leg-dot {
334
+ width: 7px;
335
+ height: 7px;
336
+ border-radius: 50%;
337
+ flex-shrink: 0;
338
+ }
339
+ .leg-dot--cam {
340
+ background: #4a9eff;
341
+ }
342
+ .leg-dot--boat {
343
+ background: #ff4a4a;
344
+ }
345
+ .leg-dot--det {
346
+ background: #00e5a0;
347
+ }
348
+ .leg-line {
349
+ width: 14px;
350
+ height: 1px;
351
+ flex-shrink: 0;
352
+ }
353
+ .leg-line--rings {
354
+ background: rgba(74, 158, 255, 0.4);
355
+ }
356
+ .leg-line--arc {
357
+ background: rgba(74, 158, 255, 0.25);
358
+ border-top: 1px dashed rgba(74, 158, 255, 0.4);
359
+ height: 0;
360
+ padding-top: 1px;
361
+ }
362
+
363
+ .zoom-btn {
364
+ width: 26px;
365
+ height: 26px;
366
+ background: rgba(8, 14, 20, 0.9);
367
+ border: 1px solid var(--border-bright);
368
+ color: var(--text);
369
+ font-family: var(--font);
370
+ font-size: 15px;
371
+ cursor: pointer;
372
+ display: flex;
373
+ align-items: center;
374
+ justify-content: center;
375
+ transition: border-color 0.12s;
376
+ }
377
+ .zoom-btn:hover {
378
+ border-color: var(--accent);
379
+ color: var(--accent);
380
  }
381
 
382
+ @media (max-width: 700px) {
383
+ .coastal-surveillance-sim {
384
+ grid-template-columns: 1fr;
385
+ grid-template-rows: 38px 55vw 1fr;
386
+ }
387
+ #panel {
388
+ border-left: none;
389
+ border-top: 1px solid var(--border);
390
+ }
391
  }