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