| <!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> |