coastkeeper-sim / app.js
kevinconka's picture
Refactor trail management in app.js to streamline code and improve performance
d97b165
;(function () {
'use strict'
function $(id) {
return document.getElementById(id)
}
function svgEl(tag, attrs) {
var el = document.createElementNS('http://www.w3.org/2000/svg', tag)
if (attrs) for (var k in attrs) el.setAttribute(k, attrs[k])
return el
}
function rgba(triplet, alpha) {
return 'rgba(' + triplet + ',' + alpha + ')'
}
var svg = null
var wrap = null
var W = 0
var H = 0
var camCX = 0
var camCY = 0
var SCALE = 0
var zoomLevel = 1.0
/** World km from camera to farthest canvas corner when zoom label = 100%; +/- zoom scales this radius. */
var BASE_VIEW_RADIUS_KM = 1.5
/** Keeps map labels readable vs panel type (~15% bump; pair with .coastal-surveillance-sim font-size). */
var MAP_LABEL_SCALE = 1.15
var worldG = null
var bgSky = null
var bgSea = null
var horizLine = null
var ringsG = null
var compassG = null
var zonesG = null
var dynamicCamG = null
var coneEl = null
var camPointer = null
var camDot = null
var camCorners = []
var boatG = null
var boatHalo = null
var boatIconG = null
var boatIconScaleG = null
var boatHull = null
var trajLine = null
var boatTrail = null
var bearingLine = null
var anchorSOuter = null
var anchorS = null
var anchorEOuter = null
var anchorE = null
var anchorSLabel = null
var anchorELabel = null
var trailFlat = []
var TRAIL_CAP = 100
var TRAIL_MIN_DIST = 0.0028
/** Min blind+detected metres before showing blind share (avoids noisy % on tiny streaks). */
var MIN_BLIND_SHARE_M = 10
var lastTrailIconAngle = NaN
var FONT_SANS = 'Saira Condensed, Arial Narrow, Arial, sans-serif'
var statCache = {}
var lastTrackSx = NaN
var lastTrackSy = NaN
var lastTrackEx = NaN
var lastTrackEy = NaN
var lastDrawScale = NaN
var prevInFov = null
var boatStart = { x: 0.0, y: 1.0 }
var boatEnd = { x: 0.0, y: 0.15 }
var dragging = null
var camHeading = 0
var boatT = 0
var boatDir = 1
var lastTime = null
var prevVisible = null
var detectedStreak = 0
var blindStreak = 0
var lastDetectedStreak = 0
var lastBlindStreak = 0
var prevDetectedStreak = 0
var prevBlindStreak = 0
var lastPinchDist = null
var running = false
var camDir = -1
var inpFov = null
var inpFrontSpeed = null
var inpBackSpeed = null
var inpFrontArc = null
var inpBoatSpeed = null
var cachedConeFov = NaN
var cachedConeR = NaN
var cachedPtrLen = NaN
var lastDrawBx = NaN
var lastDrawBy = NaN
var hitL = 0
var hitT = 0
var hitSx = 1
var hitSy = 1
var resizeRaf = 0
var panelStatsRaf = 0
var panelStatsPayload = null
var zoomVisualRaf = 0
var C = {
p: '', d: '', s: '', w: '', s1: '', s2: '',
pr: '', dr: '', sr: '', wr: '',
pc: '', dc: '', sc: '', wc: '', hi: '', lo: '',
}
var systemQuery = window.matchMedia('(prefers-color-scheme: light)')
function loadColors() {
var cs = getComputedStyle(document.querySelector('.coastal-surveillance-sim'))
function v(n) { return cs.getPropertyValue(n).trim() }
C.p = v('--primary'); C.d = v('--danger'); C.s = v('--success'); C.w = v('--warning')
C.s1 = v('--surface-1'); C.s2 = v('--surface-2')
C.pr = v('--primary-rgb'); C.dr = v('--danger-rgb'); C.sr = v('--success-rgb'); C.wr = v('--warning-rgb')
C.pc = v('--primary-content'); C.dc = v('--danger-content'); C.sc = v('--success-content'); C.wc = v('--warning-content')
C.hi = v('--content-hi'); C.lo = v('--content-lo')
if (horizLine) horizLine.setAttribute('stroke', rgba(C.pr, 0.4))
if (zoomVisualRaf) { cancelAnimationFrame(zoomVisualRaf); zoomVisualRaf = 0 }
buildWorldElements()
buildZones()
prevInFov = null
syncThemedSceneStrokes()
if (panelStatsRaf) { cancelAnimationFrame(panelStatsRaf); panelStatsRaf = 0 }
panelStatsPayload = null
}
function initTheme() {
var btns = document.querySelectorAll('.theme-btn')
function applyTheme(pref) {
var sim = document.querySelector('.coastal-surveillance-sim')
sim.dataset.theme = pref === 'system' ? (systemQuery.matches ? 'light' : 'dark') : pref
loadColors()
for (var i = 0; i < btns.length; i++)
btns[i].classList.toggle('active', btns[i].dataset.themeVal === pref)
}
applyTheme(localStorage.getItem('theme') || 'system')
systemQuery.addEventListener('change', function () {
if (localStorage.getItem('theme') === 'system') applyTheme('system')
})
for (var j = 0; j < btns.length; j++)
btns[j].addEventListener('click', function () {
localStorage.setItem('theme', this.dataset.themeVal)
applyTheme(this.dataset.themeVal)
})
}
function resetStreaks() {
detectedStreak = blindStreak = lastDetectedStreak = lastBlindStreak = 0
prevDetectedStreak = prevBlindStreak = prevVisible = null
}
function clearTrail(resetIconAngle) {
trailFlat.length = 0
if (resetIconAngle !== false) lastTrailIconAngle = NaN
if (boatTrail) boatTrail.setAttribute('points', '')
}
function flushTrailPoly() {
if (!boatTrail || !trailFlat.length) return
var s = trailFlat[0] + ',' + trailFlat[1]
for (var i = 2; i < trailFlat.length; i += 2)
s += ' ' + trailFlat[i] + ',' + trailFlat[i + 1]
boatTrail.setAttribute('points', s)
}
function appendTrail(bx, by) {
var m = trailFlat.length
if (m >= 2) {
var lx = trailFlat[m - 2]
var ly = trailFlat[m - 1]
if (Math.hypot(bx - lx, by - ly) < TRAIL_MIN_DIST) return
}
trailFlat.push(bx, by)
while (trailFlat.length > TRAIL_CAP * 2) {
trailFlat.shift()
trailFlat.shift()
}
flushTrailPoly()
}
function sectorPath(fromDeg, toDeg, R, maxSteps) {
var span = ((toDeg - fromDeg) + 360) % 360
var steps = Math.ceil(span / 2)
steps = maxSteps != null && maxSteps > 0
? Math.max(4, Math.min(maxSteps, Math.max(steps, 4)))
: Math.max(64, steps)
var d = 'M 0 0'
for (var i = 0; i <= steps; i++) {
var deg = fromDeg + (span / steps) * i
var rad = deg * Math.PI / 180
d += ' L ' + (Math.sin(rad) * R) + ' ' + (Math.cos(rad) * R)
}
return d + ' Z'
}
function zoneFillRadiusWorld() {
if (SCALE === 0) return 0.1
function distWorld(px, py) {
return Math.hypot((px - camCX) / SCALE, (camCY - py) / SCALE)
}
return Math.max(Math.max(distWorld(0, 0), distWorld(W, 0), distWorld(W, H), distWorld(0, H)) * 1.08, 0.04)
}
function coneRadiusWorld() {
if (SCALE === 0) return 0.1
return Math.max(zoneFillRadiusWorld(), Math.hypot(W, H) / SCALE * 1.12)
}
function syncConeAndPointerShape(p) {
if (!coneEl || SCALE === 0) return
var fov = p ? p.fov : +inpFov.value
var R = coneRadiusWorld()
var ptrLen = 32 / SCALE
if (fov === cachedConeFov && Math.abs(R - cachedConeR) < 1e-6 && Math.abs(ptrLen - cachedPtrLen) < 1e-12) return
cachedConeFov = fov
cachedConeR = R
cachedPtrLen = ptrLen
var half = fov / 2
coneEl.setAttribute('d', sectorPath(-half, half, R, 22))
camPointer.setAttribute('x2', 0)
camPointer.setAttribute('y2', ptrLen)
}
function syncThemedSceneStrokes() {
if (!coneEl) return
coneEl.setAttribute('fill', rgba(C.pr, 0.12))
coneEl.setAttribute('stroke', rgba(C.pr, 0.6))
camPointer.setAttribute('stroke', rgba(C.pr, 0.9))
camDot.setAttribute('fill', C.p)
camDot.setAttribute('stroke', rgba(C.pr, 0.5))
trajLine.setAttribute('stroke', rgba(C.dr, 0.25))
bearingLine.setAttribute('stroke', rgba(C.dr, 0.12))
if (boatTrail) boatTrail.setAttribute('stroke', rgba(C.dr, 0.55))
for (var i = 0; i < 4; i++) camCorners[i].setAttribute('stroke', rgba(C.pr, 0.5))
anchorSOuter.setAttribute('stroke', rgba(C.dr, 0.72))
anchorS.setAttribute('fill', rgba(C.dr, 0.52))
anchorS.setAttribute('stroke', rgba(C.dr, 0.45))
anchorEOuter.setAttribute('stroke', rgba(C.wr, 0.72))
anchorE.setAttribute('fill', rgba(C.wr, 0.48))
anchorE.setAttribute('stroke', rgba(C.wr, 0.42))
anchorSLabel.setAttribute('fill', rgba(C.dr, 0.88))
anchorELabel.setAttribute('fill', rgba(C.wr, 0.88))
}
function updateTrackSvgGeomIfNeeded() {
if (boatStart.x === lastTrackSx && boatStart.y === lastTrackSy &&
boatEnd.x === lastTrackEx && boatEnd.y === lastTrackEy) return
if (!isNaN(lastTrackSx)) clearTrail()
lastTrackSx = boatStart.x
lastTrackSy = boatStart.y
lastTrackEx = boatEnd.x
lastTrackEy = boatEnd.y
trajLine.setAttribute('x1', boatStart.x)
trajLine.setAttribute('y1', boatStart.y)
trajLine.setAttribute('x2', boatEnd.x)
trajLine.setAttribute('y2', boatEnd.y)
anchorSOuter.setAttribute('cx', boatStart.x)
anchorSOuter.setAttribute('cy', boatStart.y)
anchorS.setAttribute('cx', boatStart.x)
anchorS.setAttribute('cy', boatStart.y)
anchorEOuter.setAttribute('cx', boatEnd.x)
anchorEOuter.setAttribute('cy', boatEnd.y)
anchorE.setAttribute('cx', boatEnd.x)
anchorE.setAttribute('cy', boatEnd.y)
var syOff = 14 * MAP_LABEL_SCALE / SCALE
anchorSLabel.setAttribute('x', boatStart.x)
anchorSLabel.setAttribute('y', -(boatStart.y - syOff))
anchorELabel.setAttribute('x', boatEnd.x)
anchorELabel.setAttribute('y', -(boatEnd.y - syOff))
}
function updateScaleOnlySvgGeom() {
var cr = 8 / SCALE
var cornerPx = 14 / SCALE
camDot.setAttribute('r', cr)
for (var ci = 0; ci < 4; ci++) {
var wx = (ci === 0 || ci === 3 ? -1 : 1) * cornerPx
var wy = (ci < 2 ? 1 : -1) * cornerPx
camCorners[ci].setAttribute('x1', wx * 0.4)
camCorners[ci].setAttribute('y1', wy * 0.4)
camCorners[ci].setAttribute('x2', wx)
camCorners[ci].setAttribute('y2', wy)
}
var rHit = 6 / SCALE
var rRing = 10 / SCALE
anchorS.setAttribute('r', rHit)
anchorSOuter.setAttribute('r', rRing)
anchorE.setAttribute('r', rHit)
anchorEOuter.setAttribute('r', rRing)
boatHalo.setAttribute('r', 22 / SCALE)
var fsA = 11 * MAP_LABEL_SCALE / SCALE
anchorSLabel.setAttribute('font-size', fsA)
anchorELabel.setAttribute('font-size', fsA)
var iconPx = 15 / SCALE
boatIconScaleG.setAttribute('transform', 'scale(' + iconPx + ')')
syncConeAndPointerShape(null)
lastTrackSx = NaN
}
function buildLineZone(x1, y1, x2, y2) {
return svgEl('line', {
x1: x1, y1: y1, x2: x2, y2: y2,
stroke: rgba(C.pr, 0.5), 'stroke-width': 1, 'stroke-dasharray': '5 5',
'vector-effect': 'non-scaling-stroke',
})
}
function buildZones() {
if (!zonesG || SCALE === 0) return
while (zonesG.firstChild) zonesG.removeChild(zonesG.firstChild)
var frontArc = +inpFrontArc.value
var fh = frontArc / 2
var R = zoneFillRadiusWorld()
zonesG.appendChild(svgEl('path', { d: sectorPath(-fh, fh, R), fill: rgba(C.pr, 0.07), stroke: 'none' }))
zonesG.appendChild(svgEl('path', { d: sectorPath(fh, 360 - fh, R), fill: rgba(C.dr, 0.06), stroke: 'none' }))
var e1r = (-fh) * Math.PI / 180
var e2r = fh * Math.PI / 180
zonesG.appendChild(buildLineZone(0, 0, Math.sin(e1r) * R, Math.cos(e1r) * R))
zonesG.appendChild(buildLineZone(0, 0, Math.sin(e2r) * R, Math.cos(e2r) * R))
var labelR = 80 / SCALE
var fs = 11 * MAP_LABEL_SCALE / SCALE
var tf = svgEl('text', { x: 0, y: -labelR, transform: 'scale(1,-1)',
fill: C.pc, 'font-size': fs, 'font-family': FONT_SANS, 'text-anchor': 'middle' })
tf.textContent = 'FRONT ' + frontArc + '\u00b0'
zonesG.appendChild(tf)
var tb = svgEl('text', { x: 0, y: 18 * MAP_LABEL_SCALE / SCALE, transform: 'scale(1,-1)',
fill: C.dc, 'font-size': fs, 'font-family': FONT_SANS, 'text-anchor': 'middle' })
tb.textContent = 'BACK ' + (360 - frontArc) + '\u00b0'
zonesG.appendChild(tb)
var edgeTxt = '\u00b1' + fh.toFixed(0) + '\u00b0'
for (var ei = 0; ei < 2; ei++) {
var rad = ei ? e2r : e1r
var tx = svgEl('text', {
x: Math.sin(rad) * labelR, y: -(Math.cos(rad) * labelR),
transform: 'scale(1,-1)', fill: C.lo,
'font-size': fs, 'font-family': FONT_SANS, 'text-anchor': 'middle',
})
tx.textContent = edgeTxt
zonesG.appendChild(tx)
}
}
function fmtDist(d) {
return d < 1 ? Math.round(d * 1000) + 'm' : d.toFixed(d < 10 ? 2 : 1).replace(/\.0+$/, '') + 'km'
}
function buildWorldElements() {
if (!ringsG || !compassG || SCALE === 0) return
while (ringsG.firstChild) ringsG.removeChild(ringsG.firstChild)
var nice = [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0]
var ringInterval = nice[0]
for (var i = 0; i < nice.length; i++) {
var px = nice[i] * SCALE
if (px >= 100 && px <= 240) { ringInterval = nice[i]; break }
if (px < 100) ringInterval = nice[i]
}
var maxDist = viewportMaxCornerPx() / SCALE
var maxRing = Math.ceil(maxDist / ringInterval) + 1
var fs = 12 * MAP_LABEL_SCALE / SCALE
var tw = 5 / SCALE
for (var ri = 1; ri <= maxRing; ri++) {
var r = ri * ringInterval
ringsG.appendChild(svgEl('circle', { cx: 0, cy: 0, r: r, fill: 'none',
stroke: rgba(C.pr, 0.25), 'stroke-width': 0.5, 'vector-effect': 'non-scaling-stroke' }))
ringsG.appendChild(svgEl('line', { x1: -tw, y1: r, x2: tw, y2: r,
stroke: rgba(C.pr, 0.7), 'stroke-width': 1, 'vector-effect': 'non-scaling-stroke' }))
var lbl = svgEl('text', { x: 8 * MAP_LABEL_SCALE / SCALE, y: -r, transform: 'scale(1,-1)',
fill: C.pc, 'font-size': fs, 'font-family': FONT_SANS })
lbl.textContent = fmtDist(r)
ringsG.appendChild(lbl)
}
while (compassG.firstChild) compassG.removeChild(compassG.firstChild)
var compassR = Math.min(camCX * 0.88, camCY * 0.92) / SCALE
var fsC = 11 * MAP_LABEL_SCALE / SCALE
compassG.appendChild(svgEl('line', { x1: 0, y1: 0, x2: 0, y2: compassR + 20 / SCALE,
stroke: rgba(C.pr, 0.2), 'stroke-width': 0.5, 'stroke-dasharray': (4 / SCALE) + ' ' + (4 / SCALE),
'vector-effect': 'non-scaling-stroke' }))
for (var deg = 0; deg < 360; deg += 10) {
var maj = deg % 30 === 0
var rad = deg * Math.PI / 180
var wx0 = Math.sin(rad) * compassR
var wy0 = Math.cos(rad) * compassR
var tlen = (maj ? 8 : 4) / SCALE
compassG.appendChild(svgEl('line', {
x1: wx0, y1: wy0, x2: Math.sin(rad) * (compassR + tlen), y2: Math.cos(rad) * (compassR + tlen),
stroke: rgba(C.pr, maj ? 0.65 : 0.35),
'stroke-width': maj ? 1 : 0.5,
'vector-effect': 'non-scaling-stroke',
}))
if (maj) {
var lr = compassR + (8 + 10) * MAP_LABEL_SCALE / SCALE
var t = svgEl('text', { x: Math.sin(rad) * lr, y: -Math.cos(rad) * lr, transform: 'scale(1,-1)',
fill: C.lo, 'font-size': fsC, 'font-family': FONT_SANS, 'text-anchor': 'middle', 'dominant-baseline': 'middle' })
t.textContent = deg + '\u00b0'
compassG.appendChild(t)
}
}
}
function updateWorldTransform() {
if (worldG) worldG.setAttribute('transform',
'translate(' + camCX + ',' + camCY + ') scale(' + SCALE + ',' + (-SCALE) + ')')
}
function refreshSvgHitBox() {
if (!svg || W <= 0 || H <= 0) return
var r = svg.getBoundingClientRect()
if (r.width < 1 || r.height < 1) return
hitL = r.left
hitT = r.top
hitSx = W / r.width
hitSy = H / r.height
}
function rebuildWorldSvg() {
buildWorldElements()
buildZones()
if (coneEl && SCALE > 0) {
updateScaleOnlySvgGeom()
lastDrawScale = SCALE
}
refreshSvgHitBox()
}
function viewportMaxCornerPx() {
if (W <= 0 || H <= 0) return 1
return Math.max(
Math.hypot(camCX, camCY),
Math.hypot(W - camCX, camCY),
Math.hypot(camCX, H - camCY),
Math.hypot(W - camCX, H - camCY)
)
}
function resize() {
if (!svg || !wrap) return
W = wrap.clientWidth
H = wrap.clientHeight
svg.setAttribute('width', W)
svg.setAttribute('height', H)
camCX = W / 2
camCY = H * 0.9
SCALE = viewportMaxCornerPx() / BASE_VIEW_RADIUS_KM * zoomLevel
updateWorldTransform()
if (bgSky) { bgSky.setAttribute('width', W); bgSky.setAttribute('height', camCY) }
if (bgSea) { bgSea.setAttribute('y', camCY); bgSea.setAttribute('width', W); bgSea.setAttribute('height', H - camCY) }
if (horizLine) { horizLine.setAttribute('x2', W); horizLine.setAttribute('y1', camCY); horizLine.setAttribute('y2', camCY) }
rebuildWorldSvg()
}
function updateZoomLabel() {
var el = $('zoom-label')
if (el) el.textContent = Math.round(zoomLevel * 100) + '%'
}
function flushZoomVisuals() {
zoomVisualRaf = 0
if (!svg || SCALE <= 0) return
updateWorldTransform()
rebuildWorldSvg()
}
function scheduleZoomVisuals() {
if (!zoomVisualRaf) zoomVisualRaf = requestAnimationFrame(flushZoomVisuals)
}
function applyZoom(delta) {
var factor = delta > 0 ? 1.12 : 1 / 1.12
zoomLevel = Math.max(0.3, Math.min(8, zoomLevel * factor))
SCALE = viewportMaxCornerPx() / BASE_VIEW_RADIUS_KM * zoomLevel
scheduleZoomVisuals()
updateZoomLabel()
}
function getParams() {
return {
fov: +inpFov.value,
frontSpeed: +inpFrontSpeed.value,
backSpeed: +inpBackSpeed.value,
frontArc: +inpFrontArc.value,
boatSpeed: +inpBoatSpeed.value,
}
}
function worldToCanvas(wx, wy) {
return { x: camCX + wx * SCALE, y: camCY - wy * SCALE }
}
function canvasToWorld(cx, cy) {
return { x: (cx - camCX) / SCALE, y: (camCY - cy) / SCALE }
}
function isFront(heading, arc) {
var h = ((heading % 360) + 360) % 360
if (h > 180) h -= 360
return Math.abs(h) <= arc / 2
}
function setDir(d) {
camDir = d
var cw = $('dir-cw')
var ccw = $('dir-ccw')
if (cw) cw.classList.toggle('active', d === 1)
if (ccw) ccw.classList.toggle('active', d === -1)
}
function isBoatVisible(bx, by, heading, fovVal) {
var boatB = (Math.atan2(bx, by) * 180) / Math.PI
/* Cone uses rotate(heading) inside worldG scale(S,-S); boresight world bearing = -heading in this atan2 convention. */
var rel = (((boatB + heading + 540) % 360) - 180)
return Math.abs(rel) <= fovVal / 2 && by > -0.05
}
function draw(p, bx, by, inFov) {
if (!coneEl || SCALE === 0) return
if (SCALE !== lastDrawScale) {
lastDrawScale = SCALE
updateScaleOnlySvgGeom()
}
updateTrackSvgGeomIfNeeded()
syncConeAndPointerShape(p)
dynamicCamG.setAttribute('transform', 'rotate(' + camHeading + ')')
var segX = boatEnd.x - boatStart.x
var segY = boatEnd.y - boatStart.y
var udx = segX * boatDir
var udy = segY * boatDir
var pl = Math.hypot(segX, segY)
/* Bow is drawn along +icon Y (world north). worldG uses scale(S,-S), so screen dir ∝ (udx,-udy). */
var iconAngle = 0
if (pl > 1e-5) {
iconAngle = (Math.atan2(udy, udx) * 180) / Math.PI - 90
lastTrailIconAngle = iconAngle
} else if (!isNaN(lastTrailIconAngle)) iconAngle = lastTrailIconAngle
boatIconG.setAttribute('transform', 'rotate(' + iconAngle + ')')
if (bx !== lastDrawBx || by !== lastDrawBy) {
lastDrawBx = bx
lastDrawBy = by
bearingLine.setAttribute('x2', bx)
bearingLine.setAttribute('y2', by)
boatG.setAttribute('transform', 'translate(' + bx + ',' + by + ')')
}
if (prevInFov !== inFov) {
prevInFov = inFov
boatHull.setAttribute('fill', inFov ? C.s : C.d)
boatHull.setAttribute('stroke', inFov ? rgba(C.sr, 0.9) : rgba(C.dr, 0.85))
}
if (inFov) {
boatHalo.setAttribute('display', '')
boatHalo.setAttribute('fill', rgba(C.sr, 0.14))
} else boatHalo.setAttribute('display', 'none')
}
function schedulePanelStats(p, bx, by, inFov) {
panelStatsPayload = { p: p, bx: bx, by: by, inFov: inFov }
if (!panelStatsRaf) panelStatsRaf = requestAnimationFrame(function () {
panelStatsRaf = 0
if (!panelStatsPayload) return
var q = panelStatsPayload
panelStatsPayload = null
updateStats(q.p, q.bx, q.by, q.inFov)
})
}
function updateStats(p, bx, by, inFov) {
var c = statCache
var dist = Math.hypot(bx, by)
var st = p.frontArc / p.frontSpeed + (360 - p.frontArc) / p.backSpeed
function u(el, k, s) {
if (el && c[k] !== s) { el.textContent = s; c[k] = s }
}
u(c.elScan, 'lastScan', st.toFixed(1) + 's')
u(c.elDist, 'lastDist', (dist * 1000).toFixed(0) + 'm')
var travelM = (inFov ? detectedStreak : lastDetectedStreak).toFixed(1) + 'm'
var blindM = (!inFov ? blindStreak : lastBlindStreak).toFixed(1) + 'm'
if (c.elTravel) {
u(c.elTravel, 'lastTravel', travelM)
var to = inFov ? '1' : '0.5'
if (c.lastTravelOp !== to) { c.elTravel.style.opacity = to; c.lastTravelOp = to }
}
if (c.elBlind) {
u(c.elBlind, 'lastBlind', blindM)
var bo = !inFov ? '1' : '0.5'
if (c.lastBlindOp !== bo) { c.elBlind.style.opacity = bo; c.lastBlindOp = bo }
}
u(c.elTravelPrev, 'lastTravelPrev', prevDetectedStreak > 0 ? 'prev: ' + prevDetectedStreak.toFixed(1) + 'm' : 'prev: —')
u(c.elBlindPrev, 'lastBlindPrev', prevBlindStreak > 0 ? 'prev: ' + prevBlindStreak.toFixed(1) + 'm' : 'prev: —')
var prevTotalM = prevDetectedStreak + prevBlindStreak
if (c.elRisk && c.elRiskBar) {
if (prevTotalM >= MIN_BLIND_SHARE_M) {
var share = (prevBlindStreak / prevTotalM) * 100
var shareT = share.toFixed(0) + '%'
var col = share > 66 ? 'var(--danger)' : share > 33 ? 'var(--warning)' : 'var(--success)'
var w = share + '%'
u(c.elRisk, 'lastRiskT', shareT)
if (c.lastRiskCol !== col) { c.elRisk.style.color = col; c.lastRiskCol = col }
if (c.lastRiskW !== w) { c.elRiskBar.style.width = w; c.lastRiskW = w }
if (c.lastRiskBg !== col) { c.elRiskBar.style.background = col; c.lastRiskBg = col }
} else {
u(c.elRisk, 'lastRiskT', '—')
if (c.lastRiskW !== '0%') { c.elRiskBar.style.width = '0%'; c.lastRiskW = '0%' }
}
}
var vis = inFov ? 'YES' : 'NO'
var visCls = 'val ' + (inFov ? 'active' : 'inactive')
if (c.elVis) {
u(c.elVis, 'lastVis', vis)
if (c.lastVisCls !== visCls) { c.elVis.className = visCls; c.lastVisCls = visCls }
}
}
function animate(ts) {
if (!running || !svg) return
if (lastTime === null) lastTime = ts
var dt = Math.min((ts - lastTime) / 1000, 0.1)
lastTime = ts
var p = getParams()
var boatKmS = p.boatSpeed * 0.000514
var pathLen = Math.hypot(boatEnd.x - boatStart.x, boatEnd.y - boatStart.y)
if (pathLen > 0.001) {
boatT += boatDir * (boatKmS * dt) / pathLen
if (boatT >= 1) { boatT = 1; boatDir = -1 }
if (boatT <= 0) { boatT = 0; boatDir = 1 }
}
var speed = isFront(camHeading, p.frontArc) ? p.frontSpeed : p.backSpeed
camHeading -= camDir * speed * dt
if (camHeading >= 180) camHeading -= 360
if (camHeading < -180) camHeading += 360
var bx = boatStart.x + (boatEnd.x - boatStart.x) * boatT
var by = boatStart.y + (boatEnd.y - boatStart.y) * boatT
var visible = isBoatVisible(bx, by, camHeading, p.fov)
var distThisFrame = p.boatSpeed * 0.514 * dt
if (coneEl && SCALE > 0 && pathLen > 0.001 && !visible) appendTrail(bx, by)
if (prevVisible !== null && visible !== prevVisible) {
if (visible) {
clearTrail(false)
prevBlindStreak = blindStreak
lastBlindStreak = blindStreak
blindStreak = 0
} else {
prevDetectedStreak = detectedStreak
lastDetectedStreak = detectedStreak
detectedStreak = 0
}
}
if (visible) detectedStreak += distThisFrame
else blindStreak += distThisFrame
prevVisible = visible
if (!coneEl || SCALE === 0) updateStats(p, bx, by, visible)
else {
draw(p, bx, by, visible)
schedulePanelStats(p, bx, by, visible)
}
if (running) requestAnimationFrame(animate)
}
function getCanvasXY(e) {
var src = 'touches' in e && e.touches.length ? e.touches[0] : e
return { x: (src.clientX - hitL) * hitSx, y: (src.clientY - hitT) * hitSy }
}
function onResize() {
if (!resizeRaf) resizeRaf = requestAnimationFrame(function () { resizeRaf = 0; resize() })
}
function tryDragStart(e, thresh) {
var pt = getCanvasXY(e)
var s = worldToCanvas(boatStart.x, boatStart.y)
var end = worldToCanvas(boatEnd.x, boatEnd.y)
if (Math.hypot(pt.x - s.x, pt.y - s.y) < thresh) dragging = 'start'
else if (Math.hypot(pt.x - end.x, pt.y - end.y) < thresh) dragging = 'end'
}
function dragToWorld(e) {
var pt = getCanvasXY(e)
var w = canvasToWorld(pt.x, pt.y)
w.y = Math.max(0.05, Math.min(1.3, w.y))
w.x = Math.max(-1.4, Math.min(1.4, w.x))
if (dragging === 'start') boatStart = w
else if (dragging === 'end') boatEnd = w
}
function onMouseDown(e) {
lastPointerEv = e
tryDragStart(e, 26)
updateSvgCursor(e)
}
var CURSOR_NEAR = 30
var lastPointerEv = null
function updateSvgCursor(e) {
if (!svg || !SCALE) return
if (dragging) {
svg.style.cursor = 'grabbing'
return
}
var pt = getCanvasXY(e)
var s = worldToCanvas(boatStart.x, boatStart.y)
var end = worldToCanvas(boatEnd.x, boatEnd.y)
if (Math.hypot(pt.x - s.x, pt.y - s.y) < CURSOR_NEAR ||
Math.hypot(pt.x - end.x, pt.y - end.y) < CURSOR_NEAR)
svg.style.cursor = 'grab'
else svg.style.cursor = ''
}
function onMouseMove(e) {
lastPointerEv = e
if (dragging) dragToWorld(e)
updateSvgCursor(e)
}
function onMouseUp() {
dragging = null
if (svg) {
if (lastPointerEv) updateSvgCursor(lastPointerEv)
else svg.style.cursor = ''
}
}
function onTouchStart(e) {
if (e.touches.length === 2) {
lastPinchDist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY)
dragging = null
return
}
if (e.touches.length !== 1) return
e.preventDefault()
tryDragStart(e, 34)
}
function onTouchMove(e) {
if (e.touches.length === 2 && lastPinchDist !== null) {
e.preventDefault()
var dist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY)
applyZoom(dist - lastPinchDist)
lastPinchDist = dist
return
}
if (!dragging) return
e.preventDefault()
dragToWorld(e)
}
function onTouchEnd(e) {
dragging = null
if (e.touches.length < 2) lastPinchDist = null
}
function onWheel(e) {
e.preventDefault()
applyZoom(-e.deltaY)
}
function bindRange(id, valSpanId, onInput) {
var input = $(id)
var span = valSpanId ? $(valSpanId) : null
function sync() { if (span) span.textContent = input.value }
input.addEventListener('input', function () {
sync()
resetStreaks()
if (onInput) onInput()
})
sync()
}
/** VOS #1 = shipped defaults; proposal #2 uses 50° FOV (slider is degrees, not a zoom %). */
var CAMERA_PRESETS = {
vos1: { fov: 18, frontSpeed: 6, backSpeed: 60, frontArc: 180 },
prop1: { fov: 18, frontSpeed: 7, backSpeed: 120, frontArc: 125 },
prop2: { fov: 50, frontSpeed: 8, backSpeed: 120, frontArc: 125 },
}
function clearPresetDropdown() {
var sel = $('camera-preset')
if (sel) sel.value = ''
}
function applyCameraPreset(key) {
var p = CAMERA_PRESETS[key]
if (!p || !inpFov) return
inpFov.value = String(p.fov)
inpFrontSpeed.value = String(p.frontSpeed)
inpBackSpeed.value = String(p.backSpeed)
inpFrontArc.value = String(p.frontArc)
$('val-fov').textContent = inpFov.value
$('val-front-speed').textContent = inpFrontSpeed.value
$('val-back-speed').textContent = inpBackSpeed.value
$('val-front-arc').textContent = inpFrontArc.value
cachedConeFov = cachedPtrLen = NaN
resetStreaks()
buildZones()
var sel = $('camera-preset')
if (sel) sel.value = key
}
function init() {
svg = $('c')
wrap = $('canvas-wrap')
if (!svg || !wrap) return
bgSky = svgEl('rect', { x: 0, y: 0, fill: 'var(--surface-1)' })
bgSea = svgEl('rect', { x: 0, y: 0, fill: 'var(--surface-2)' })
horizLine = svgEl('line', { x1: 0, 'stroke-dasharray': '6 4' })
ringsG = svgEl('g', { id: 'rings' })
compassG = svgEl('g', { id: 'compass' })
zonesG = svgEl('g', { id: 'zones' })
worldG = svgEl('g', { id: 'world' })
worldG.appendChild(compassG)
worldG.appendChild(zonesG)
worldG.appendChild(ringsG)
dynamicCamG = svgEl('g', { id: 'dynamic-cam' })
coneEl = svgEl('path', { 'stroke-linejoin': 'round', 'stroke-width': 1, 'vector-effect': 'non-scaling-stroke' })
camPointer = svgEl('line', { x1: 0, y1: 0, x2: 0, y2: 0.1, 'stroke-width': 1.5, 'vector-effect': 'non-scaling-stroke' })
dynamicCamG.appendChild(coneEl)
dynamicCamG.appendChild(camPointer)
trajLine = svgEl('line', { 'stroke-width': 1, 'stroke-dasharray': '4 3', 'vector-effect': 'non-scaling-stroke' })
boatTrail = svgEl('polyline', {
fill: 'none', 'stroke-width': 2, 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
'vector-effect': 'non-scaling-stroke',
})
bearingLine = svgEl('line', { x1: 0, y1: 0, 'stroke-width': 0.5, 'vector-effect': 'non-scaling-stroke' })
boatG = svgEl('g', { id: 'boat-layer' })
boatHalo = svgEl('circle', { cx: 0, cy: 0, stroke: 'none' })
boatIconG = svgEl('g')
boatIconScaleG = svgEl('g')
boatHull = svgEl('path', {
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',
'stroke-width': 1.2, 'stroke-linejoin': 'round', 'vector-effect': 'non-scaling-stroke',
})
boatIconScaleG.appendChild(boatHull)
boatIconG.appendChild(boatIconScaleG)
boatG.appendChild(boatHalo)
boatG.appendChild(boatIconG)
anchorSOuter = svgEl('circle', {
class: 'drag-anchor-ring drag-anchor-ring--start',
fill: 'none', 'stroke-width': 2.5, 'vector-effect': 'non-scaling-stroke',
})
anchorS = svgEl('circle', { class: 'drag-anchor-core drag-anchor-core--start', 'stroke-width': 1.5, 'vector-effect': 'non-scaling-stroke' })
anchorEOuter = svgEl('circle', {
class: 'drag-anchor-ring drag-anchor-ring--end',
fill: 'none', 'stroke-width': 2.5, 'vector-effect': 'non-scaling-stroke',
})
anchorE = svgEl('circle', { class: 'drag-anchor-core drag-anchor-core--end', 'stroke-width': 1.5, 'vector-effect': 'non-scaling-stroke' })
anchorSLabel = svgEl('text', {
class: 'drag-anchor-label',
'text-anchor': 'middle', 'dominant-baseline': 'middle', transform: 'scale(1,-1)', 'font-family': FONT_SANS,
})
anchorSLabel.textContent = 'S'
anchorELabel = svgEl('text', {
class: 'drag-anchor-label drag-anchor-label--end',
'text-anchor': 'middle', 'dominant-baseline': 'middle', transform: 'scale(1,-1)', 'font-family': FONT_SANS,
})
anchorELabel.textContent = 'E'
camDot = svgEl('circle', { cx: 0, cy: 0, 'stroke-width': 1.5, 'vector-effect': 'non-scaling-stroke' })
for (var ci = 0; ci < 4; ci++) camCorners.push(svgEl('line', { 'stroke-width': 1, 'vector-effect': 'non-scaling-stroke' }))
worldG.appendChild(dynamicCamG)
worldG.appendChild(trajLine)
worldG.appendChild(boatTrail)
worldG.appendChild(bearingLine)
worldG.appendChild(boatG)
worldG.appendChild(anchorSOuter)
worldG.appendChild(anchorS)
worldG.appendChild(anchorEOuter)
worldG.appendChild(anchorE)
worldG.appendChild(anchorSLabel)
worldG.appendChild(anchorELabel)
worldG.appendChild(camDot)
for (var cj = 0; cj < 4; cj++) worldG.appendChild(camCorners[cj])
svg.appendChild(bgSky)
svg.appendChild(bgSea)
svg.appendChild(horizLine)
svg.appendChild(worldG)
inpFov = $('input-fov')
inpFrontSpeed = $('input-front-speed')
inpBackSpeed = $('input-back-speed')
inpFrontArc = $('input-front-arc')
inpBoatSpeed = $('input-boat-speed')
cachedConeFov = cachedConeR = cachedPtrLen = NaN
lastDrawBx = lastDrawBy = NaN
initTheme()
running = true
resize()
updateZoomLabel()
setDir(-1)
window.addEventListener('resize', onResize)
svg.addEventListener('mousedown', onMouseDown)
svg.addEventListener('mousemove', onMouseMove)
svg.addEventListener('mouseup', onMouseUp)
svg.addEventListener('mouseleave', function () {
if (!dragging) svg.style.cursor = ''
})
svg.addEventListener('touchstart', onTouchStart, { passive: false })
svg.addEventListener('touchmove', onTouchMove, { passive: false })
svg.addEventListener('touchend', onTouchEnd)
svg.addEventListener('touchcancel', onTouchEnd)
svg.addEventListener('wheel', onWheel, { passive: false })
$('zoom-out').addEventListener('click', function () { applyZoom(-1) })
$('zoom-in').addEventListener('click', function () { applyZoom(1) })
$('dir-cw').addEventListener('click', function () { setDir(1) })
$('dir-ccw').addEventListener('click', function () { setDir(-1) })
bindRange('input-fov', 'val-fov', function () {
cachedConeFov = cachedPtrLen = NaN
clearPresetDropdown()
})
bindRange('input-front-speed', 'val-front-speed', clearPresetDropdown)
bindRange('input-back-speed', 'val-back-speed', clearPresetDropdown)
bindRange('input-front-arc', 'val-front-arc', function () {
clearPresetDropdown()
buildZones()
})
bindRange('input-boat-speed', 'val-boat-speed')
var presetSelect = $('camera-preset')
if (presetSelect) {
presetSelect.addEventListener('change', function () {
var k = presetSelect.value
if (k) applyCameraPreset(k)
})
}
statCache.elScan = $('st-scan')
statCache.elDist = $('st-dist')
statCache.elTravel = $('st-travel')
statCache.elBlind = $('st-blind')
statCache.elTravelPrev = $('st-travel-prev')
statCache.elBlindPrev = $('st-blind-prev')
statCache.elRisk = $('st-risk')
statCache.elRiskBar = $('st-risk-bar')
statCache.elVis = $('st-vis')
requestAnimationFrame(animate)
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init)
else init()
})()