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