Spaces:
Running
Running
Implement Coastal Surveillance Simulator with HTML, CSS, and JavaScript. Added interactive canvas for simulation, control panel for camera and boat settings, and responsive design for better usability.
c40323b | ;(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() | |
| })() | |