;(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() })()