Sh4b4n's picture
Update index.html
7bd983c verified
<!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();
// Set canvas pixel dimensions
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
// Set CSS display dimensions
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
// Reset and apply correct transformation (not cumulative)
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;
// Space for labels below visualization
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;
}
// Clear with sky
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);
// Draw beach (stop before label area)
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);
// Draw sea (stop before label area)
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);
// Draw waves
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);
// Draw FOV cone fill - Fills region between upper ray, lower ray, and ground
ctx.fillStyle = 'rgba(0, 200, 83, 0.25)';
ctx.beginPath();
ctx.moveTo(mastX, mastTopY);
// Trace upper ray to its endpoint
upperPoints.forEach(p => ctx.lineTo(p.x, p.y));
// From upper ray endpoint, go straight down to ground level
const lastUpper = upperPoints[upperPoints.length - 1];
ctx.lineTo(lastUpper.x, groundY);
// Draw along ground to lower ray endpoint
const lastLower = lowerPoints[lowerPoints.length - 1];
ctx.lineTo(lastLower.x, groundY);
// Trace lower ray back to camera
for (let i = lowerPoints.length - 1; i >= 0; i--) {
ctx.lineTo(lowerPoints[i].x, lowerPoints[i].y);
}
ctx.closePath();
ctx.fill();
// Draw FOV rays
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([]);
// Draw mast
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);
// Draw camera
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();
// Draw distance markers in label area below sea
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);
}
// Draw height scale
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);
}
// Labels
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();
}
// Camera profile handler
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:
// For custom, let user manually adjust values
return;
}
// Trigger update after changing values
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>