;(function () { 'use strict' function $(id) { return document.getElementById(id) } var canvas = null var wrap = null var ctx = null var W = 0 var H = 0 var camCX = 0 var camCY = 0 var SCALE = 0 var zoomLevel = 1.0 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 rafId = 0 var running = false var camDir = -1 function resetStreaks() { detectedStreak = 0 blindStreak = 0 lastDetectedStreak = 0 lastBlindStreak = 0 prevDetectedStreak = 0 prevBlindStreak = 0 prevVisible = null } function resize() { if (!wrap || !canvas) return W = wrap.clientWidth H = wrap.clientHeight canvas.width = W canvas.height = H camCX = W / 2 camCY = H * 0.88 SCALE = Math.min(W, H) * 0.42 * zoomLevel } function updateZoomLabel() { var el = $('zoom-label') if (el) el.textContent = Math.round(zoomLevel * 100) + '%' } function applyZoom(delta) { var factor = delta > 0 ? 1.12 : 1 / 1.12 zoomLevel = Math.max(0.3, Math.min(8, zoomLevel * factor)) SCALE = Math.min(W, H) * 0.42 * zoomLevel updateZoomLabel() } function getParams() { return { fov: +$('input-fov').value, frontSpeed: +$('input-front-speed').value, backSpeed: +$('input-back-speed').value, frontArc: +$('input-front-arc').value, boatSpeed: +$('input-boat-speed').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 scanTime(p) { return p.frontArc / p.frontSpeed + (360 - p.frontArc) / p.backSpeed } 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 boatAngle(bx, by) { return (Math.atan2(bx, by) * 180) / Math.PI } function isBoatVisible(bx, by, heading, fovVal) { var rel = (((boatAngle(bx, by) - heading + 540) % 360) - 180) return Math.abs(rel) <= fovVal / 2 && by > -0.05 } function drawDirIndicator(d) { if (!ctx) return var cx = 30, cy = 30, r = 14 ctx.beginPath() ctx.arc(cx, cy, r, 0, Math.PI * 2) ctx.strokeStyle = 'rgba(74,158,255,0.2)' ctx.lineWidth = 1 ctx.stroke() var start = -Math.PI / 2 var end = start + (d > 0 ? 1.2 : -1.2) ctx.beginPath() ctx.arc(cx, cy, r - 3, start, end, d < 0) ctx.strokeStyle = 'rgba(74,158,255,0.7)' ctx.lineWidth = 2 ctx.stroke() var tx = cx + Math.cos(end) * (r - 3) var ty = cy + Math.sin(end) * (r - 3) ctx.beginPath() ctx.arc(tx, ty, 2, 0, Math.PI * 2) ctx.fillStyle = 'rgba(74,158,255,0.7)' ctx.fill() ctx.fillStyle = 'rgba(74,158,255,0.55)' ctx.font = "7px 'Courier New', monospace" ctx.textAlign = 'center' ctx.fillText(d > 0 ? 'CW' : 'CCW', cx, cy + 3) } function draw(p, bx, by, inFov) { if (!ctx) return ctx.clearRect(0, 0, W, H) ctx.fillStyle = '#060d16' ctx.fillRect(0, 0, W, H) ctx.fillStyle = '#06100a' ctx.fillRect(0, camCY, W, H - camCY) ctx.beginPath() ctx.moveTo(0, camCY) ctx.lineTo(W, camCY) ctx.strokeStyle = 'rgba(74,158,255,0.3)' ctx.lineWidth = 1 ctx.setLineDash([6, 4]) ctx.stroke() ctx.setLineDash([]) var niceIntervals = [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0] var targetMinPx = 100 var targetMaxPx = 240 var ringInterval = niceIntervals[0] for (var i = 0; i < niceIntervals.length; i++) { var iv = niceIntervals[i] var px = iv * SCALE if (px >= targetMinPx && px <= targetMaxPx) { ringInterval = iv break } if (px < targetMinPx) ringInterval = iv } var maxDist = Math.sqrt(Math.pow(Math.max(camCX, W - camCX), 2) + Math.pow(camCY, 2)) var maxRingCount = Math.ceil(maxDist / (ringInterval * SCALE)) + 1 function fmtDist(d) { return d < 1 ? Math.round(d * 1000) + 'm' : d.toFixed(d < 10 ? 2 : 1).replace(/\.0+$/, '') + 'km' } for (var ri = 1; ri <= maxRingCount; ri++) { var r = ri * ringInterval * SCALE ctx.beginPath() ctx.arc(camCX, camCY, r, 0, Math.PI * 2) ctx.strokeStyle = 'rgba(74,158,255,0.10)' ctx.lineWidth = 0.5 ctx.stroke() ctx.beginPath() ctx.moveTo(camCX - 5, camCY - r) ctx.lineTo(camCX + 5, camCY - r) ctx.strokeStyle = 'rgba(74,158,255,0.5)' ctx.lineWidth = 1 ctx.stroke() ctx.fillStyle = 'rgba(74,158,255,0.55)' ctx.font = "10px 'Courier New', monospace" ctx.textAlign = 'left' ctx.fillText(fmtDist(ri * ringInterval), camCX + 8, camCY - r + 4) } var compassR = Math.min(camCX * 0.88, camCY * 0.92) var canvasBase = -Math.PI / 2 for (var deg = 0; deg < 360; deg += 10) { var a = canvasBase + (deg * Math.PI) / 180 var isMajor = deg % 30 === 0 var r0 = compassR var r1 = compassR + (isMajor ? 8 : 4) ctx.beginPath() ctx.moveTo(camCX + Math.cos(a) * r0, camCY + Math.sin(a) * r0) ctx.lineTo(camCX + Math.cos(a) * r1, camCY + Math.sin(a) * r1) ctx.strokeStyle = isMajor ? 'rgba(74,158,255,0.5)' : 'rgba(74,158,255,0.2)' ctx.lineWidth = isMajor ? 1 : 0.5 ctx.stroke() if (isMajor) { var lx = camCX + Math.cos(a) * (r1 + 10) var ly = camCY + Math.sin(a) * (r1 + 10) ctx.fillStyle = 'rgba(74,158,255,0.4)' ctx.font = "8px 'Courier New', monospace" ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillText(deg + '°', lx, ly) ctx.textBaseline = 'alphabetic' } } ctx.beginPath() ctx.moveTo(camCX, camCY) ctx.lineTo(camCX, 0) ctx.strokeStyle = 'rgba(74,158,255,0.12)' ctx.lineWidth = 0.5 ctx.setLineDash([4, 4]) ctx.stroke() ctx.setLineDash([]) var frontHalf = p.frontArc / 2 var frontA1 = canvasBase - (frontHalf * Math.PI) / 180 var frontA2 = canvasBase + (frontHalf * Math.PI) / 180 var INF = Math.max(W, H) * 3 ctx.save() ctx.beginPath() ctx.moveTo(camCX, camCY) ctx.lineTo(camCX + Math.cos(frontA1) * INF, camCY + Math.sin(frontA1) * INF) ctx.arc(camCX, camCY, INF, frontA1, frontA2) ctx.closePath() ctx.fillStyle = 'rgba(74,158,255,0.03)' ctx.fill() ctx.beginPath() ctx.moveTo(camCX, camCY) ctx.lineTo(camCX + Math.cos(frontA2) * INF, camCY + Math.sin(frontA2) * INF) ctx.arc(camCX, camCY, INF, frontA2, frontA1 + Math.PI * 2) ctx.closePath() ctx.fillStyle = 'rgba(255,74,74,0.03)' ctx.fill() ctx.restore() for (var ai = 0; ai < 2; ai++) { var aedge = ai === 0 ? frontA1 : frontA2 ctx.beginPath() ctx.moveTo(camCX, camCY) ctx.lineTo(camCX + Math.cos(aedge) * INF, camCY + Math.sin(aedge) * INF) ctx.strokeStyle = 'rgba(74,158,255,0.3)' ctx.lineWidth = 1 ctx.setLineDash([5, 5]) ctx.stroke() ctx.setLineDash([]) } var labelR = 80 ctx.font = "8px 'Courier New', monospace" ctx.textAlign = 'center' ctx.fillStyle = 'rgba(74,158,255,0.45)' ctx.fillText('FRONT ' + p.frontArc + '°', camCX, camCY - labelR) ctx.fillStyle = 'rgba(255,74,74,0.35)' if (camCY + 18 < H - 2) ctx.fillText('BACK ' + (360 - p.frontArc) + '°', camCX, camCY + 18) ctx.fillStyle = 'rgba(74,158,255,0.4)' ctx.fillText('±' + frontHalf.toFixed(0) + '°', camCX + Math.cos(frontA1) * labelR, camCY + Math.sin(frontA1) * labelR) ctx.fillText('±' + frontHalf.toFixed(0) + '°', camCX + Math.cos(frontA2) * labelR, camCY + Math.sin(frontA2) * labelR) var camRad = (camHeading * Math.PI) / 180 var fovRad = (p.fov * Math.PI) / 180 var coneLen = Math.max(W, H) * 2 ctx.beginPath() ctx.moveTo(camCX, camCY) ctx.arc(camCX, camCY, coneLen, canvasBase + camRad - fovRad / 2, canvasBase + camRad + fovRad / 2) ctx.closePath() ctx.fillStyle = 'rgba(74,158,255,0.07)' ctx.fill() ctx.beginPath() ctx.moveTo(camCX, camCY) ctx.arc(camCX, camCY, coneLen, canvasBase + camRad - fovRad / 2, canvasBase + camRad + fovRad / 2) ctx.closePath() ctx.strokeStyle = 'rgba(74,158,255,0.6)' ctx.lineWidth = 1 ctx.stroke() ctx.beginPath() ctx.moveTo(camCX, camCY) ctx.lineTo(camCX + Math.cos(canvasBase + camRad) * 32, camCY + Math.sin(canvasBase + camRad) * 32) ctx.strokeStyle = 'rgba(74,158,255,0.9)' ctx.lineWidth = 1.5 ctx.stroke() var bC = worldToCanvas(bx, by) var bsC = worldToCanvas(boatStart.x, boatStart.y) var beC = worldToCanvas(boatEnd.x, boatEnd.y) ctx.beginPath() ctx.moveTo(bsC.x, bsC.y) ctx.lineTo(beC.x, beC.y) ctx.strokeStyle = 'rgba(255,74,74,0.25)' ctx.lineWidth = 1 ctx.setLineDash([4, 3]) ctx.stroke() ctx.setLineDash([]) ctx.beginPath() ctx.moveTo(camCX, camCY) ctx.lineTo(bC.x, bC.y) ctx.strokeStyle = 'rgba(255,74,74,0.12)' ctx.lineWidth = 0.5 ctx.stroke() if (inFov) { ctx.beginPath() ctx.arc(bC.x, bC.y, 14, 0, Math.PI * 2) ctx.fillStyle = 'rgba(0,229,160,0.12)' ctx.fill() } ctx.beginPath() ctx.arc(bC.x, bC.y, 5, 0, Math.PI * 2) ctx.fillStyle = inFov ? '#00e5a0' : '#ff4a4a' ctx.fill() ctx.strokeStyle = inFov ? 'rgba(0,229,160,0.5)' : 'rgba(255,74,74,0.4)' ctx.lineWidth = 1.5 ctx.stroke() ctx.font = "9px 'Courier New', monospace" ctx.textAlign = 'left' ctx.fillStyle = inFov ? 'rgba(0,229,160,0.7)' : 'rgba(255,100,100,0.6)' ctx.fillText('TGT', bC.x + 8, bC.y - 4) ctx.beginPath() ctx.arc(bsC.x, bsC.y, 4, 0, Math.PI * 2) ctx.fillStyle = '#ff4a4a' ctx.fill() ctx.beginPath() ctx.arc(beC.x, beC.y, 4, 0, Math.PI * 2) ctx.fillStyle = '#ff9944' ctx.fill() ctx.font = "8px 'Courier New', monospace" ctx.fillStyle = 'rgba(255,74,74,0.5)' ctx.textAlign = 'center' ctx.fillText('S', bsC.x, bsC.y + 14) ctx.fillStyle = 'rgba(255,153,68,0.5)' ctx.fillText('E', beC.x, beC.y + 14) var cr = 8 ctx.beginPath() ctx.arc(camCX, camCY, cr, 0, Math.PI * 2) ctx.fillStyle = '#4a9eff' ctx.fill() ctx.strokeStyle = 'rgba(74,158,255,0.5)' ctx.lineWidth = 1.5 ctx.stroke() var corners = [ [-14, -14], [14, -14], [14, 14], [-14, 14], ] for (var ci = 0; ci < corners.length; ci++) { var dx = corners[ci][0], dy = corners[ci][1] ctx.beginPath() ctx.moveTo(camCX + dx * 0.4, camCY + dy * 0.4) ctx.lineTo(camCX + dx, camCY + dy) ctx.strokeStyle = 'rgba(74,158,255,0.5)' ctx.lineWidth = 1 ctx.stroke() } drawDirIndicator(camDir) var dist = Math.sqrt(bx * bx + by * by) var h360 = ((camHeading % 360) + 360) % 360 var st = scanTime(p) function setText(id, text) { var el = $(id) if (el) el.textContent = text } setText('st-scan', st.toFixed(1) + 's') setText('st-angle', h360.toFixed(1) + '°') setText('st-dist', (dist * 1000).toFixed(0) + 'm') setText('hdr-scan', st.toFixed(1) + 's') setText('hdr-angle', h360.toFixed(1) + '°') var travelEl = $('st-travel') var blindEl = $('st-blind') var travelPrevEl = $('st-travel-prev') var blindPrevEl = $('st-blind-prev') var riskEl = $('st-risk') var riskBarEl = $('st-risk-bar') if (travelEl) { travelEl.textContent = (inFov ? detectedStreak : lastDetectedStreak).toFixed(1) + 'm' travelEl.style.opacity = inFov ? '1' : '0.5' } if (blindEl) { blindEl.textContent = (!inFov ? blindStreak : lastBlindStreak).toFixed(1) + 'm' blindEl.style.opacity = !inFov ? '1' : '0.5' } if (travelPrevEl) travelPrevEl.textContent = prevDetectedStreak > 0 ? 'prev: ' + prevDetectedStreak.toFixed(1) + 'm' : 'prev: —' if (blindPrevEl) blindPrevEl.textContent = prevBlindStreak > 0 ? 'prev: ' + prevBlindStreak.toFixed(1) + 'm' : 'prev: —' var total = prevDetectedStreak + prevBlindStreak if (riskEl && riskBarEl) { if (total > 0) { var risk = (prevBlindStreak / total) * 100 riskEl.textContent = risk.toFixed(0) + '%' riskEl.style.color = risk > 66 ? '#ff4a4a' : risk > 33 ? '#ffcc44' : '#00e5a0' riskBarEl.style.width = risk + '%' riskBarEl.style.background = risk > 66 ? '#ff4a4a' : risk > 33 ? '#ffcc44' : '#00e5a0' } else { riskEl.textContent = '—' riskBarEl.style.width = '0%' } } var visEl = $('st-vis') if (visEl) { visEl.textContent = inFov ? 'YES' : 'NO' visEl.className = 'val ' + (inFov ? 'active' : 'inactive') } } function animate(ts) { if (!running || !ctx) 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) { var dtT = (boatKmS * dt) / pathLen boatT += boatDir * dtT if (boatT >= 1) { boatT = 1 boatDir = -1 } if (boatT <= 0) { boatT = 0 boatDir = 1 } } var front = isFront(camHeading, p.frontArc) var speed = front ? 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 (prevVisible !== null && visible !== prevVisible) { if (visible) { prevBlindStreak = blindStreak lastBlindStreak = blindStreak blindStreak = 0 } else { prevDetectedStreak = detectedStreak lastDetectedStreak = detectedStreak detectedStreak = 0 } } if (visible) detectedStreak += distThisFrame else blindStreak += distThisFrame prevVisible = visible draw(p, bx, by, visible) if (running) rafId = requestAnimationFrame(animate) } function getCanvasXY(e) { if (!canvas) return { x: 0, y: 0 } var rect = canvas.getBoundingClientRect() var sx = W / rect.width var sy = H / rect.height var src = 'touches' in e && e.touches.length ? e.touches[0] : e return { x: (src.clientX - rect.left) * sx, y: (src.clientY - rect.top) * sy } } function onResize() { resize() } function onMouseDown(e) { if (!canvas) return var pt = getCanvasXY(e) var bsC = worldToCanvas(boatStart.x, boatStart.y) var beC = worldToCanvas(boatEnd.x, boatEnd.y) if (Math.hypot(pt.x - bsC.x, pt.y - bsC.y) < 14) dragging = 'start' else if (Math.hypot(pt.x - beC.x, pt.y - beC.y) < 14) dragging = 'end' } function onMouseMove(e) { if (!dragging) return 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 boatEnd = w } function onMouseUp() { dragging = null } 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() var pt = getCanvasXY(e) var bsC = worldToCanvas(boatStart.x, boatStart.y) var beC = worldToCanvas(boatEnd.x, boatEnd.y) if (Math.hypot(pt.x - bsC.x, pt.y - bsC.y) < 18) dragging = 'start' else if (Math.hypot(pt.x - beC.x, pt.y - beC.y) < 18) dragging = 'end' } 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, ) var delta = dist - lastPinchDist applyZoom(delta) lastPinchDist = dist return } if (!dragging) return e.preventDefault() 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 boatEnd = w } function onTouchEnd(e) { dragging = null if (e.touches.length < 2) lastPinchDist = null } function onWheel(e) { e.preventDefault() applyZoom(-e.deltaY) } function bindRange(id, valSpanId) { var input = $(id) var span = valSpanId ? $(valSpanId) : null function sync() { if (span) span.textContent = input.value } input.addEventListener('input', function () { sync() resetStreaks() }) sync() } function init() { canvas = $('c') wrap = $('canvas-wrap') if (!canvas || !wrap) return ctx = canvas.getContext('2d') if (!ctx) return running = true resize() updateZoomLabel() setDir(-1) window.addEventListener('resize', onResize) canvas.addEventListener('mousedown', onMouseDown) canvas.addEventListener('mousemove', onMouseMove) canvas.addEventListener('mouseup', onMouseUp) canvas.addEventListener('touchstart', onTouchStart, { passive: false }) canvas.addEventListener('touchmove', onTouchMove, { passive: false }) canvas.addEventListener('touchend', onTouchEnd) canvas.addEventListener('touchcancel', onTouchEnd) canvas.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') bindRange('input-front-speed', 'val-front-speed') bindRange('input-back-speed', 'val-back-speed') bindRange('input-front-arc', 'val-front-arc') bindRange('input-boat-speed', 'val-boat-speed') rafId = requestAnimationFrame(animate) } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init) else init() })()