|
|
<!DOCTYPE html> |
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>CoastKeeper Range Calculator</title> |
|
|
<style> |
|
|
* { |
|
|
margin: 0; |
|
|
padding: 0; |
|
|
box-sizing: border-box; |
|
|
} |
|
|
|
|
|
body { |
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
|
min-height: 100vh; |
|
|
} |
|
|
|
|
|
.container { |
|
|
max-width: 100%; |
|
|
height: 100vh; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
padding: 20px; |
|
|
gap: 20px; |
|
|
} |
|
|
|
|
|
.header { |
|
|
background: rgba(255, 255, 255, 0.95); |
|
|
backdrop-filter: blur(10px); |
|
|
border-radius: 20px; |
|
|
padding: 25px; |
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); |
|
|
} |
|
|
|
|
|
.header h1 { |
|
|
color: #667eea; |
|
|
margin-bottom: 20px; |
|
|
font-size: 28px; |
|
|
font-weight: 600; |
|
|
} |
|
|
|
|
|
.controls-grid { |
|
|
display: grid; |
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); |
|
|
gap: 15px; |
|
|
margin-bottom: 15px; |
|
|
} |
|
|
|
|
|
.control-group { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
} |
|
|
|
|
|
.control-group label { |
|
|
margin-bottom: 6px; |
|
|
color: #333; |
|
|
font-weight: 500; |
|
|
font-size: 13px; |
|
|
} |
|
|
|
|
|
.control-group input, |
|
|
.control-group select { |
|
|
padding: 10px; |
|
|
border: 2px solid #e0e0e0; |
|
|
border-radius: 10px; |
|
|
font-size: 14px; |
|
|
transition: border-color 0.3s; |
|
|
background: white; |
|
|
} |
|
|
|
|
|
.control-group input:focus, |
|
|
.control-group select:focus { |
|
|
outline: none; |
|
|
border-color: #667eea; |
|
|
} |
|
|
|
|
|
.computed-fov { |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
|
color: white; |
|
|
padding: 15px; |
|
|
border-radius: 12px; |
|
|
margin-top: 10px; |
|
|
font-size: 14px; |
|
|
font-weight: 500; |
|
|
} |
|
|
|
|
|
.result-banner { |
|
|
background: linear-gradient(135deg, #00c853 0%, #00e676 100%); |
|
|
color: white; |
|
|
padding: 15px 20px; |
|
|
border-radius: 12px; |
|
|
margin-top: 10px; |
|
|
text-align: center; |
|
|
box-shadow: 0 4px 15px rgba(0, 200, 83, 0.3); |
|
|
} |
|
|
|
|
|
.result-banner .label { |
|
|
font-size: 14px; |
|
|
opacity: 0.9; |
|
|
margin-bottom: 5px; |
|
|
} |
|
|
|
|
|
.result-banner .value { |
|
|
font-size: 24px; |
|
|
font-weight: 700; |
|
|
} |
|
|
|
|
|
.main-content { |
|
|
background: rgba(255, 255, 255, 0.95); |
|
|
backdrop-filter: blur(10px); |
|
|
border-radius: 20px; |
|
|
padding: 20px; |
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); |
|
|
flex: 1; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
overflow: hidden; |
|
|
} |
|
|
|
|
|
.chart-container { |
|
|
position: relative; |
|
|
background: linear-gradient(to bottom, #87CEEB 0%, #87CEEB 70%, #4A90A4 100%); |
|
|
border-radius: 15px; |
|
|
overflow: hidden; |
|
|
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.1); |
|
|
flex: 1; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
} |
|
|
|
|
|
#canvas { |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
display: block; |
|
|
} |
|
|
|
|
|
.info-box { |
|
|
margin-top: 15px; |
|
|
padding: 12px; |
|
|
background: #f8f9fa; |
|
|
border-radius: 10px; |
|
|
font-size: 12px; |
|
|
color: #666; |
|
|
line-height: 1.5; |
|
|
} |
|
|
|
|
|
@media (max-width: 768px) { |
|
|
.controls-grid { |
|
|
grid-template-columns: 1fr; |
|
|
} |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="container"> |
|
|
<div class="header"> |
|
|
<h1> 📷 CoastKeeper Range Calculator</h1> |
|
|
|
|
|
<div class="controls-grid"> |
|
|
<div class="control-group"> |
|
|
<label for="cameraProfile">Camera Profile</label> |
|
|
<select id="cameraProfile"> |
|
|
<option value="custom">Custom</option> |
|
|
<option value="ir">IR: EVIDIR 640x480 - 25mm</option> |
|
|
<option value="rgb">RGB: BASLER 3840x2160 - 7.8mm</option> |
|
|
</select> |
|
|
</div> |
|
|
|
|
|
<div class="control-group"> |
|
|
<label for="mast">Mast height (m)</label> |
|
|
<input type="number" id="mast" step="0.1" value="20.0" min="3" max="40"> |
|
|
</div> |
|
|
|
|
|
<div class="control-group"> |
|
|
<label for="tilt">Tilt ° (down +)</label> |
|
|
<input type="number" id="tilt" step="0.1" value="5.0" min="0" max="15"> |
|
|
</div> |
|
|
|
|
|
<div class="control-group"> |
|
|
<label for="resolution">Sensor resolution (px)</label> |
|
|
<select id="resolution"> |
|
|
<option value="320x240">320x240</option> |
|
|
<option value="384x288">384x288</option> |
|
|
<option value="640x480" selected>640x480</option> |
|
|
<option value="640x512">640x512</option> |
|
|
<option value="1280x1024">1280x1024</option> |
|
|
<option value="1920x1080">1920x1080</option> |
|
|
<option value="3840x2160">3840x2160</option> |
|
|
</select> |
|
|
</div> |
|
|
|
|
|
<div class="control-group"> |
|
|
<label for="pixelPitch">Pixel pitch (µm)</label> |
|
|
<input type="number" id="pixelPitch" step="0.1" value="12.0"> |
|
|
</div> |
|
|
|
|
|
<div class="control-group"> |
|
|
<label for="focalLength">Lens focal length (mm)</label> |
|
|
<input type="number" id="focalLength" step="0.1" value="25.0"> |
|
|
</div> |
|
|
|
|
|
<div class="control-group"> |
|
|
<label for="seaDist">Distance to sea (m)</label> |
|
|
<input type="number" id="seaDist" step="1" value="50" min="0" max="500"> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="computed-fov" id="computedFov"> |
|
|
<strong>Computed FoVs:</strong> Horizontal = 0.00°, Vertical = 0.00° |
|
|
</div> |
|
|
|
|
|
<div class="result-banner"> |
|
|
<div class="label">Valid Range</div> |
|
|
<div class="value" id="validRange">0 m - 0 m</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="main-content"> |
|
|
<div class="chart-container"> |
|
|
<canvas id="canvas"></canvas> |
|
|
</div> |
|
|
|
|
|
<div class="info-box"> |
|
|
📊 <strong>Side View Visualization:</strong> Thermal camera mounted on mast. |
|
|
VFOV computed from sensor parameters and lens. Beach (sandy) and sea (blue) zones shown. |
|
|
FOV rays show upper/lower limits and center. Valid range indicates where objects can be detected. |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
const canvas = document.getElementById('canvas'); |
|
|
const ctx = canvas.getContext('2d'); |
|
|
|
|
|
function resizeCanvas() { |
|
|
const container = canvas.parentElement; |
|
|
const dpr = window.devicePixelRatio || 1; |
|
|
const rect = container.getBoundingClientRect(); |
|
|
|
|
|
|
|
|
canvas.width = rect.width * dpr; |
|
|
canvas.height = rect.height * dpr; |
|
|
|
|
|
|
|
|
canvas.style.width = rect.width + 'px'; |
|
|
canvas.style.height = rect.height + 'px'; |
|
|
|
|
|
|
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); |
|
|
} |
|
|
|
|
|
function distGround(angleDeg, h) { |
|
|
if (angleDeg <= 0) return Infinity; |
|
|
if (angleDeg >= 90) return 0; |
|
|
return h / Math.tan(angleDeg * Math.PI / 180); |
|
|
} |
|
|
|
|
|
function computeFOVs() { |
|
|
const resolution = document.getElementById('resolution').value; |
|
|
const [resX, resY] = resolution.split('x').map(Number); |
|
|
const pixelPitch = parseFloat(document.getElementById('pixelPitch').value); |
|
|
const focalLength = parseFloat(document.getElementById('focalLength').value); |
|
|
|
|
|
const pixelPitchMm = pixelPitch / 1000; |
|
|
const hFOV = 2 * Math.atan((resX * pixelPitchMm) / (2 * focalLength)) * 180 / Math.PI; |
|
|
const vFOV = 2 * Math.atan((resY * pixelPitchMm) / (2 * focalLength)) * 180 / Math.PI; |
|
|
|
|
|
return { hFOV, vFOV }; |
|
|
} |
|
|
|
|
|
function draw() { |
|
|
const mast = parseFloat(document.getElementById('mast').value); |
|
|
const tilt = parseFloat(document.getElementById('tilt').value); |
|
|
const seaDist = parseFloat(document.getElementById('seaDist').value); |
|
|
|
|
|
const { hFOV, vFOV } = computeFOVs(); |
|
|
|
|
|
document.getElementById('computedFov').innerHTML = |
|
|
`<strong>Computed FoVs:</strong> Horizontal = ${hFOV.toFixed(2)}°, Vertical = ${vFOV.toFixed(2)}°`; |
|
|
|
|
|
const lowerAng = tilt + vFOV / 2; |
|
|
const upperAng = tilt - vFOV / 2; |
|
|
const centerAng = tilt; |
|
|
|
|
|
const lowerDist = distGround(lowerAng, mast); |
|
|
const upperDist = distGround(upperAng, mast); |
|
|
|
|
|
const minRange = Math.min(lowerDist, upperDist); |
|
|
const maxRange = Math.max(lowerDist, upperDist); |
|
|
|
|
|
let rangeText; |
|
|
if (maxRange === Infinity) { |
|
|
rangeText = `${minRange.toFixed(0)} m - horizon`; |
|
|
} else { |
|
|
rangeText = `${minRange.toFixed(0)} m - ${maxRange.toFixed(0)} m`; |
|
|
} |
|
|
|
|
|
document.getElementById('validRange').textContent = rangeText; |
|
|
|
|
|
const w = canvas.width / (window.devicePixelRatio || 1); |
|
|
const h = canvas.height / (window.devicePixelRatio || 1); |
|
|
|
|
|
const viewDistanceMax = 500; |
|
|
const viewHeightMax = 60; |
|
|
|
|
|
|
|
|
const labelAreaHeight = 25; |
|
|
|
|
|
const margin = 40; |
|
|
const scaleX = (w - 2 * margin) / viewDistanceMax; |
|
|
const scaleY = (h - 2 * margin - labelAreaHeight) / viewHeightMax; |
|
|
const scale = Math.min(scaleX, scaleY); |
|
|
|
|
|
const groundY = h - margin - labelAreaHeight; |
|
|
|
|
|
function toX(dist) { |
|
|
return margin + dist * scale; |
|
|
} |
|
|
|
|
|
function toY(height) { |
|
|
return groundY - height * scale; |
|
|
} |
|
|
|
|
|
|
|
|
const skyGradient = ctx.createLinearGradient(0, 0, 0, groundY); |
|
|
skyGradient.addColorStop(0, '#87CEEB'); |
|
|
skyGradient.addColorStop(0.7, '#B0D9E8'); |
|
|
skyGradient.addColorStop(1, '#D4E8F0'); |
|
|
ctx.fillStyle = skyGradient; |
|
|
ctx.fillRect(0, 0, w, h); |
|
|
|
|
|
|
|
|
const beachGradient = ctx.createLinearGradient(0, groundY, 0, h); |
|
|
beachGradient.addColorStop(0, '#F4D03F'); |
|
|
beachGradient.addColorStop(1, '#D4A017'); |
|
|
ctx.fillStyle = beachGradient; |
|
|
ctx.fillRect(toX(0), groundY, toX(seaDist) - toX(0), h - groundY - labelAreaHeight); |
|
|
|
|
|
|
|
|
const seaGradient = ctx.createLinearGradient(0, groundY, 0, h); |
|
|
seaGradient.addColorStop(0, '#1E90FF'); |
|
|
seaGradient.addColorStop(1, '#0066CC'); |
|
|
ctx.fillStyle = seaGradient; |
|
|
ctx.fillRect(toX(seaDist), groundY, toX(viewDistanceMax) - toX(seaDist), h - groundY - labelAreaHeight); |
|
|
|
|
|
|
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; |
|
|
ctx.lineWidth = 2; |
|
|
for (let i = 0; i < 5; i++) { |
|
|
ctx.beginPath(); |
|
|
const waveY = groundY + 5 + i * 3; |
|
|
for (let x = toX(seaDist); x < toX(viewDistanceMax); x += 10) { |
|
|
const y = waveY + Math.sin((x - toX(seaDist)) / 10 + i) * 2; |
|
|
if (x === toX(seaDist)) { |
|
|
ctx.moveTo(x, y); |
|
|
} else { |
|
|
ctx.lineTo(x, y); |
|
|
} |
|
|
} |
|
|
ctx.stroke(); |
|
|
} |
|
|
|
|
|
const mastX = toX(0); |
|
|
const mastTopY = toY(mast); |
|
|
|
|
|
function getRayPoints(angle, maxDist) { |
|
|
const points = []; |
|
|
const angleRad = angle * Math.PI / 180; |
|
|
|
|
|
for (let d = 0; d <= maxDist; d += 1) { |
|
|
const height = mast - d * Math.tan(angleRad); |
|
|
if (height < 0) break; |
|
|
points.push({ x: toX(d), y: toY(height) }); |
|
|
} |
|
|
|
|
|
const groundDist = distGround(angle, mast); |
|
|
if (groundDist < maxDist && groundDist !== Infinity) { |
|
|
points.push({ x: toX(groundDist), y: groundY }); |
|
|
} else if (points.length > 0) { |
|
|
points.push({ x: toX(maxDist), y: toY(mast - maxDist * Math.tan(angleRad)) }); |
|
|
} |
|
|
|
|
|
return points; |
|
|
} |
|
|
|
|
|
const upperPoints = getRayPoints(upperAng, viewDistanceMax); |
|
|
const lowerPoints = getRayPoints(lowerAng, viewDistanceMax); |
|
|
const centerPoints = getRayPoints(centerAng, viewDistanceMax); |
|
|
|
|
|
|
|
|
ctx.fillStyle = 'rgba(0, 200, 83, 0.25)'; |
|
|
ctx.beginPath(); |
|
|
ctx.moveTo(mastX, mastTopY); |
|
|
|
|
|
|
|
|
upperPoints.forEach(p => ctx.lineTo(p.x, p.y)); |
|
|
|
|
|
|
|
|
const lastUpper = upperPoints[upperPoints.length - 1]; |
|
|
ctx.lineTo(lastUpper.x, groundY); |
|
|
|
|
|
|
|
|
const lastLower = lowerPoints[lowerPoints.length - 1]; |
|
|
ctx.lineTo(lastLower.x, groundY); |
|
|
|
|
|
|
|
|
for (let i = lowerPoints.length - 1; i >= 0; i--) { |
|
|
ctx.lineTo(lowerPoints[i].x, lowerPoints[i].y); |
|
|
} |
|
|
|
|
|
ctx.closePath(); |
|
|
ctx.fill(); |
|
|
|
|
|
|
|
|
ctx.strokeStyle = '#00c853'; |
|
|
ctx.lineWidth = 2; |
|
|
ctx.setLineDash([8, 4]); |
|
|
|
|
|
ctx.beginPath(); |
|
|
ctx.moveTo(mastX, mastTopY); |
|
|
upperPoints.forEach(p => ctx.lineTo(p.x, p.y)); |
|
|
ctx.stroke(); |
|
|
|
|
|
ctx.beginPath(); |
|
|
ctx.moveTo(mastX, mastTopY); |
|
|
lowerPoints.forEach(p => ctx.lineTo(p.x, p.y)); |
|
|
ctx.stroke(); |
|
|
|
|
|
ctx.setLineDash([4, 4]); |
|
|
ctx.strokeStyle = '#666'; |
|
|
ctx.lineWidth = 1.5; |
|
|
ctx.beginPath(); |
|
|
ctx.moveTo(mastX, mastTopY); |
|
|
centerPoints.forEach(p => ctx.lineTo(p.x, p.y)); |
|
|
ctx.stroke(); |
|
|
ctx.setLineDash([]); |
|
|
|
|
|
|
|
|
const mastWidth = 8; |
|
|
const mastGradient = ctx.createLinearGradient(mastX - mastWidth/2, 0, mastX + mastWidth/2, 0); |
|
|
mastGradient.addColorStop(0, '#999'); |
|
|
mastGradient.addColorStop(0.5, '#CCC'); |
|
|
mastGradient.addColorStop(1, '#999'); |
|
|
ctx.fillStyle = mastGradient; |
|
|
ctx.fillRect(mastX - mastWidth/2, toY(mast), mastWidth, groundY - toY(mast)); |
|
|
|
|
|
ctx.fillStyle = '#666'; |
|
|
ctx.fillRect(mastX - mastWidth, groundY, mastWidth * 2, 8); |
|
|
|
|
|
|
|
|
const camSize = 12; |
|
|
ctx.fillStyle = '#333'; |
|
|
ctx.fillRect(mastX - camSize/2, mastTopY - camSize/2, camSize, camSize); |
|
|
|
|
|
ctx.fillStyle = '#1a1a1a'; |
|
|
ctx.beginPath(); |
|
|
ctx.arc(mastX + camSize/2, mastTopY, 4, 0, Math.PI * 2); |
|
|
ctx.fill(); |
|
|
|
|
|
ctx.fillStyle = '#00FF00'; |
|
|
ctx.beginPath(); |
|
|
ctx.arc(mastX - camSize/3, mastTopY - camSize/3, 2, 0, Math.PI * 2); |
|
|
ctx.fill(); |
|
|
|
|
|
|
|
|
ctx.fillStyle = '#333'; |
|
|
ctx.font = '11px Arial'; |
|
|
ctx.textAlign = 'center'; |
|
|
for (let d = 50; d <= viewDistanceMax; d += 50) { |
|
|
const x = toX(d); |
|
|
|
|
|
ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)'; |
|
|
ctx.lineWidth = 1; |
|
|
ctx.beginPath(); |
|
|
ctx.moveTo(x, h - labelAreaHeight); |
|
|
ctx.lineTo(x, h - labelAreaHeight + 8); |
|
|
ctx.stroke(); |
|
|
|
|
|
ctx.fillText(d + 'm', x, h - labelAreaHeight/2 + 4); |
|
|
} |
|
|
|
|
|
|
|
|
ctx.textAlign = 'right'; |
|
|
for (let height = 0; height <= viewHeightMax; height += 10) { |
|
|
const y = toY(height); |
|
|
|
|
|
ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)'; |
|
|
ctx.lineWidth = 1; |
|
|
ctx.setLineDash([2, 2]); |
|
|
ctx.beginPath(); |
|
|
ctx.moveTo(margin, y); |
|
|
ctx.lineTo(margin - 5, y); |
|
|
ctx.stroke(); |
|
|
ctx.setLineDash([]); |
|
|
|
|
|
ctx.fillText(height + 'm', margin - 8, y + 4); |
|
|
} |
|
|
|
|
|
|
|
|
ctx.font = 'bold 13px Arial'; |
|
|
ctx.fillStyle = '#00c853'; |
|
|
ctx.textAlign = 'left'; |
|
|
|
|
|
if (upperPoints.length > 20) { |
|
|
const p = upperPoints[20]; |
|
|
ctx.fillText(`${upperAng.toFixed(1)}°`, p.x + 5, p.y - 5); |
|
|
} |
|
|
|
|
|
if (lowerPoints.length > 20) { |
|
|
const p = lowerPoints[20]; |
|
|
ctx.fillText(`${lowerAng.toFixed(1)}°`, p.x + 5, p.y + 15); |
|
|
} |
|
|
} |
|
|
|
|
|
function update() { |
|
|
draw(); |
|
|
} |
|
|
|
|
|
|
|
|
document.getElementById('cameraProfile').addEventListener('change', function() { |
|
|
const profile = this.value; |
|
|
const resolutionSelect = document.getElementById('resolution'); |
|
|
const pixelPitchInput = document.getElementById('pixelPitch'); |
|
|
const focalLengthInput = document.getElementById('focalLength'); |
|
|
|
|
|
switch(profile) { |
|
|
case 'ir': |
|
|
resolutionSelect.value = '640x480'; |
|
|
pixelPitchInput.value = '12.0'; |
|
|
focalLengthInput.value = '25.0'; |
|
|
break; |
|
|
case 'rgb': |
|
|
resolutionSelect.value = '3840x2160'; |
|
|
pixelPitchInput.value = '2.1'; |
|
|
focalLengthInput.value = '7.8'; |
|
|
break; |
|
|
case 'custom': |
|
|
default: |
|
|
|
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
update(); |
|
|
}); |
|
|
|
|
|
document.getElementById('mast').addEventListener('input', update); |
|
|
document.getElementById('tilt').addEventListener('input', update); |
|
|
document.getElementById('resolution').addEventListener('change', update); |
|
|
document.getElementById('pixelPitch').addEventListener('input', update); |
|
|
document.getElementById('focalLength').addEventListener('input', update); |
|
|
document.getElementById('seaDist').addEventListener('input', update); |
|
|
|
|
|
window.addEventListener('resize', () => { |
|
|
resizeCanvas(); |
|
|
draw(); |
|
|
}); |
|
|
|
|
|
resizeCanvas(); |
|
|
draw(); |
|
|
</script> |
|
|
</body> |
|
|
</html> |