coastkeeper-sim / app.js
kevinconka's picture
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
raw
history blame
19.1 kB
;(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()
})()