858 lines
33 KiB
HTML
858 lines
33 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Regla Portland Plotter - Visor Pro Avanzado con Escala</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
<style>
|
|
input[type=range]::-webkit-slider-thumb {
|
|
background: #f59e0b;
|
|
border: 2px solid #0f172a;
|
|
height: 18px;
|
|
width: 18px;
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
}
|
|
.cursor-grab-custom { cursor: grab; }
|
|
.cursor-grab-custom:active { cursor: grabbing; }
|
|
.cursor-crosshair-custom { cursor: crosshair; }
|
|
</style>
|
|
</head>
|
|
<body class="bg-slate-950 text-slate-100 h-screen w-screen flex flex-col font-sans select-none overflow-hidden m-0 p-0">
|
|
|
|
<!-- Header -->
|
|
<header class="bg-slate-900 border-b border-slate-800 p-2.5 shadow-xl z-20 flex-shrink-0">
|
|
<div class="max-w-full px-4 mx-auto flex flex-col lg:flex-row justify-between items-center gap-2">
|
|
<div class="flex items-center gap-3">
|
|
<div class="bg-amber-500/10 p-1.5 rounded-xl border border-amber-500/30">
|
|
<i class="fa-solid fa-compass text-amber-400 text-lg"></i>
|
|
</div>
|
|
<div>
|
|
<h1 class="text-sm font-bold text-white tracking-wide flex items-center gap-2">
|
|
Regla Portland Plotter
|
|
<span class="text-[9px] bg-amber-500/20 text-amber-300 border border-amber-500/30 px-2 py-0.5 rounded-full font-mono">Modo Distancias & Escala</span>
|
|
</h1>
|
|
<p class="text-[10px] text-slate-400">Simulador Profesional de Alta Fidelidad</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Panel de Datos y Acciones -->
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<div class="flex items-center gap-3 bg-slate-950 px-3 py-1 rounded-lg border border-slate-800 font-mono text-xs">
|
|
<div><span class="text-slate-500">Rv:</span> <span id="readout-rv" class="text-amber-400 font-bold">090°</span></div>
|
|
<div class="h-3.5 w-[1px] bg-slate-800"></div>
|
|
<div><span class="text-slate-500">Ct:</span> <span id="readout-err" class="text-cyan-400 font-bold">0° E</span></div>
|
|
<div class="h-3.5 w-[1px] bg-slate-800"></div>
|
|
<div><span class="text-slate-500">Rm/Ra:</span> <span id="readout-rm" class="text-emerald-400 font-bold">090°</span></div>
|
|
</div>
|
|
|
|
<!-- Botón Marcar Escala -->
|
|
<button id="btn-mode-scale" onclick="toggleScaleMode()" class="bg-cyan-600 hover:bg-cyan-500 text-white font-bold px-3 py-1 rounded-lg text-xs transition-all flex items-center gap-1.5 shadow-md" title="Calibrar escala sobre las latitudes (5 millas)">
|
|
<i class="fa-solid fa-ruler-combined"></i> <span id="btn-scale-text">Marcar Escala (5M)</span>
|
|
</button>
|
|
|
|
<!-- Botón Ayuda -->
|
|
<button onclick="toggleHelpModal(true)" class="bg-indigo-600 hover:bg-indigo-500 text-white font-bold px-3 py-1 rounded-lg text-xs transition-all flex items-center gap-1.5 shadow-md" title="Guía de uso de la regla Portland">
|
|
<i class="fa-solid fa-circle-question"></i> Ayuda
|
|
</button>
|
|
|
|
<!-- Botón Centrar Regla -->
|
|
<button onclick="centerRulerToView()" class="bg-sky-600 hover:bg-sky-500 text-white font-bold px-3 py-1 rounded-lg text-xs transition-all flex items-center gap-1.5 shadow-md" title="Traer la regla a tu vista actual sin mover el mapa">
|
|
<i class="fa-solid fa-crosshairs"></i> Centrar Regla
|
|
</button>
|
|
|
|
<!-- Botón Trazar Puntos Múltiples -->
|
|
<button id="btn-mode-ab" onclick="toggleRouteMode()" class="bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold px-3.5 py-1 rounded-lg text-xs transition-all flex items-center gap-2 shadow-md">
|
|
<i class="fa-solid fa-pencil"></i> <span id="btn-ab-text">Añadir Puntos Ruta</span>
|
|
</button>
|
|
|
|
<!-- Botón Borrar Puntos -->
|
|
<button onclick="clearAllRoutes()" class="bg-rose-600 hover:bg-rose-500 text-white font-bold px-3 py-1 rounded-lg text-xs transition-all flex items-center gap-1.5 shadow-md" title="Eliminar todos los puntos y líneas">
|
|
<i class="fa-solid fa-trash"></i> Borrar Todo
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Contenido Principal -->
|
|
<main class="flex-grow flex flex-col lg:flex-row w-full p-2 gap-2 overflow-hidden" style="height: calc(100vh - 58px);">
|
|
|
|
<!-- PANEL DE CONTROLES (Izquierda) -->
|
|
<div class="lg:w-72 bg-slate-900 border border-slate-800 rounded-xl p-3 shadow-2xl flex flex-col gap-3 overflow-y-auto flex-shrink-0 text-xs">
|
|
|
|
<div class="border-b border-slate-800 pb-1.5">
|
|
<h2 class="text-[11px] font-bold text-amber-400 uppercase tracking-wider flex items-center gap-2">
|
|
<i class="fa-solid fa-map"></i> Carta y Zoom
|
|
</h2>
|
|
</div>
|
|
|
|
<!-- Selector de archivo local -->
|
|
<div class="bg-slate-950 p-2.5 rounded-lg border border-slate-800 space-y-2">
|
|
<label class="block text-slate-300 font-semibold">Cargar Imagen de Carta:</label>
|
|
<input type="file" id="chart-file-input" accept="image/*" class="w-full text-[10px] text-slate-400 file:mr-2 file:py-1 file:px-2 file:rounded-md file:border-0 file:text-[10px] file:font-semibold file:bg-amber-500 file:text-slate-950 hover:file:bg-amber-400 cursor-pointer">
|
|
<button onclick="resetChartImage()" class="w-full bg-slate-800 hover:bg-slate-700 text-slate-300 py-1 rounded text-[10px] transition-all">Restaurar Carta por Defecto</button>
|
|
</div>
|
|
|
|
<!-- Control de Zoom -->
|
|
<div class="bg-slate-950 p-2.5 rounded-lg border border-slate-800 space-y-2">
|
|
<div class="flex justify-between items-center">
|
|
<label class="text-slate-300 font-semibold">Zoom de Carta</label>
|
|
<span id="zoom-val" class="font-mono font-bold text-amber-400">100%</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button onclick="adjustZoom(-0.1)" class="bg-slate-800 hover:bg-slate-700 text-white px-2.5 py-1 rounded font-bold text-sm">-</button>
|
|
<input type="range" id="zoom-slider" min="0.5" max="3.0" step="0.05" value="1.0" class="w-full accent-amber-500">
|
|
<button onclick="adjustZoom(0.1)" class="bg-slate-800 hover:bg-slate-700 text-white px-2.5 py-1 rounded font-bold text-sm">+</button>
|
|
</div>
|
|
<div id="scale-status" class="text-[10px] text-cyan-400 font-mono mt-1">
|
|
Escala: No calibrada (Usar botón Marcar Escala)
|
|
</div>
|
|
</div>
|
|
|
|
<div class="border-b border-slate-800 pb-1.5 pt-1">
|
|
<h2 class="text-[11px] font-bold text-amber-400 uppercase tracking-wider flex items-center gap-2">
|
|
<i class="fa-solid fa-sliders"></i> Ajustes del Plotter
|
|
</h2>
|
|
</div>
|
|
|
|
<!-- Control de Giro del Disco -->
|
|
<div class="bg-slate-950 p-2.5 rounded-lg border border-slate-800 space-y-1.5">
|
|
<div class="flex justify-between items-center">
|
|
<label class="text-slate-300 font-semibold">Giro Rosa (Brújula N)</label>
|
|
<span id="disk-angle-val" class="font-mono font-bold text-amber-400">000°</span>
|
|
</div>
|
|
<input type="range" id="disk-angle-slider" min="0" max="360" value="0" class="w-full">
|
|
</div>
|
|
|
|
<!-- Control de Ángulo de la Regla -->
|
|
<div class="bg-slate-950 p-2.5 rounded-lg border border-slate-800 space-y-1.5">
|
|
<div class="flex justify-between items-center">
|
|
<label class="text-slate-300 font-semibold">Orientación Regla</label>
|
|
<span id="ruler-angle-val" class="font-mono font-bold text-slate-300">000°</span>
|
|
</div>
|
|
<input type="range" id="ruler-angle-slider" min="0" max="360" value="0" class="w-full">
|
|
</div>
|
|
|
|
<!-- Control de Error Total -->
|
|
<div class="bg-slate-950 p-2.5 rounded-lg border border-slate-800 space-y-1.5">
|
|
<div class="flex justify-between items-center">
|
|
<label class="text-slate-300 font-semibold">Ct = (±dm) + (±Δ)</label>
|
|
<span id="error-val" class="font-mono font-bold text-cyan-400">0° E</span>
|
|
</div>
|
|
<input type="range" id="error-slider" min="-30" max="30" value="0" class="w-full">
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- ÁREA DEL CANVAS CON SCROLL (Derecha) -->
|
|
<div id="canvas-container" class="flex-grow bg-slate-900 border border-slate-800 rounded-xl shadow-2xl relative overflow-auto h-full flex">
|
|
<canvas id="plotterCanvas" class="cursor-grab-custom block m-auto"></canvas>
|
|
|
|
<!-- Indicador de estado flotante -->
|
|
<div id="status-badge" class="absolute top-3 left-3 bg-slate-950/90 border border-amber-500/50 text-amber-300 text-xs px-3 py-1.5 rounded-lg shadow-lg pointer-events-none hidden z-10">
|
|
<i class="fa-solid fa-crosshairs mr-1.5 animate-pulse"></i> <span id="status-text">Haz clic para añadir puntos en la carta (A -> B -> C...)</span>
|
|
</div>
|
|
|
|
<!--
|
|
<div class="absolute bottom-3 right-3 bg-slate-950/80 border border-slate-800 text-[9.5px] text-slate-400 px-2.5 py-1 rounded-md backdrop-blur-sm pointer-events-none z-10">
|
|
Usa el zoom o las barras de desplazamiento para explorar la carta
|
|
</div>
|
|
-->
|
|
</div>
|
|
|
|
</main>
|
|
|
|
<!-- MODAL DE AYUDA / INSTRUCCIONES -->
|
|
<div id="help-modal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
|
|
<div class="bg-slate-900 border border-slate-800 rounded-2xl max-w-lg w-full p-6 shadow-2xl space-y-4 text-slate-300 text-xs">
|
|
<div class="flex justify-between items-center border-b border-slate-800 pb-3">
|
|
<h3 class="text-sm font-bold text-amber-400 flex items-center gap-2">
|
|
<i class="fa-solid fa-book-open"></i> Guía de Uso: Regla Portland Plotter
|
|
</h3>
|
|
<button onclick="toggleHelpModal(false)" class="text-slate-400 hover:text-white text-base">
|
|
<i class="fa-solid fa-xmark"></i>
|
|
</button>
|
|
</div>
|
|
<div class="space-y-2.5 leading-relaxed">
|
|
<p><strong>Calibrar Escala (Distancias):</strong></p>
|
|
<ul class="list-disc list-inside space-y-1 text-slate-400 pl-2">
|
|
<li>Haz clic en <strong>"Marcar Escala".</strong></li>
|
|
<li>Marca un primer punto sobre la escala de latitudes de la carta, luego un segundo punto exactamente a <strong>5 millas náuticas</strong> de distancia.</li>
|
|
</ul>
|
|
<p><strong>Trazar Rumbo y Medir Distancias:</strong></p>
|
|
<ul class="list-disc list-inside space-y-1 text-slate-400 pl-2">
|
|
<li>Usa <strong>"Añadir Puntos Ruta"</strong> para trazar waypoints (A➞B➞C). Sobre cada línea de ruta se mostrará la distancia en millas náuticas.</li>
|
|
</ul>
|
|
<p><strong>Averiguar Rumbo Verdadero (Rv):</strong></p>
|
|
<ul class="list-disc list-inside space-y-1 text-slate-400 pl-2">
|
|
<li>Coloca el borde recto de la regla uniendo tu origen y tu destino. La <strong>Gran Flecha Azul</strong> debe apuntar en el sentido de la navegación.</li>
|
|
<li>Gira el disco central de la brújula (Rosa de los Vientos) hasta que las líneas de su cuadrícula interna queden perfectamente paralelas a los <strong>meridianos (líneas Norte-Sur)</strong> y el <strong>Norte</strong> de la brújula apuntando al Norte de la carta.</li>
|
|
<li>Lee directamente en el transportador el <strong>Rumbo Verdadero (Rv)</strong> indicado por la marca de lectura fija.</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="pt-2 flex justify-end">
|
|
<button onclick="toggleHelpModal(false)" class="bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold px-4 py-1.5 rounded-lg text-xs">
|
|
Entendido
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const state = {
|
|
rulerX: 0,
|
|
rulerY: 0,
|
|
rulerAngle: 0,
|
|
diskAngle: 0,
|
|
totalError: 0,
|
|
isDragging: false,
|
|
dragStartX: 0,
|
|
dragStartY: 0,
|
|
zoom: 1.0,
|
|
mode: 'MOVE', // 'MOVE', 'ADD_POINTS', 'SCALE_P0', 'SCALE_P1'
|
|
points: [],
|
|
scalePoints: [], // Almacena [ {x, y} para P0, {x, y} para P1 ] base a zoom 1.0
|
|
pixelsPerNauticalMileBase: null, // Píxeles por milla a zoom 1.0
|
|
customChartImg: null,
|
|
baseWidth: 1400,
|
|
baseHeight: 900
|
|
};
|
|
|
|
const canvas = document.getElementById('plotterCanvas');
|
|
const ctx = canvas.getContext('2d');
|
|
const container = document.getElementById('canvas-container');
|
|
|
|
function initCanvasSize() {
|
|
canvas.width = state.baseWidth * state.zoom;
|
|
canvas.height = state.baseHeight * state.zoom;
|
|
|
|
if (state.rulerX === 0 && state.rulerY === 0) {
|
|
centerRulerAbsolute();
|
|
}
|
|
draw();
|
|
}
|
|
|
|
function centerRulerAbsolute() {
|
|
state.rulerX = canvas.width / 2;
|
|
state.rulerY = canvas.height / 2;
|
|
container.scrollTo({
|
|
left: (canvas.width - container.clientWidth) / 2,
|
|
top: (canvas.height - container.clientHeight) / 2,
|
|
behavior: 'smooth'
|
|
});
|
|
draw();
|
|
}
|
|
|
|
function centerRulerToView() {
|
|
state.rulerX = container.scrollLeft + (container.clientWidth / 2);
|
|
state.rulerY = container.scrollTop + (container.clientHeight / 2);
|
|
draw();
|
|
}
|
|
|
|
function toggleHelpModal(show) {
|
|
const modal = document.getElementById('help-modal');
|
|
if (show) modal.classList.remove('hidden');
|
|
else modal.classList.add('hidden');
|
|
}
|
|
|
|
document.getElementById('chart-file-input').addEventListener('change', function(e) {
|
|
const file = e.target.files[0];
|
|
if (file) {
|
|
const reader = new FileReader();
|
|
reader.onload = function(event) {
|
|
const img = new Image();
|
|
img.onload = function() {
|
|
state.customChartImg = img;
|
|
const maxW = 1800;
|
|
const scale = img.width > maxW ? maxW / img.width : 1;
|
|
state.baseWidth = img.width * scale;
|
|
state.baseHeight = img.height * scale;
|
|
state.pixelsPerNauticalMileBase = null; // Reiniciar escala al cambiar de carta
|
|
updateScaleStatusUI();
|
|
initCanvasSize();
|
|
centerRulerAbsolute();
|
|
};
|
|
img.src = event.target.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
});
|
|
|
|
function resetChartImage() {
|
|
state.customChartImg = null;
|
|
document.getElementById('chart-file-input').value = '';
|
|
state.baseWidth = 1400;
|
|
state.baseHeight = 900;
|
|
state.zoom = 1.0;
|
|
state.pixelsPerNauticalMileBase = null;
|
|
updateScaleStatusUI();
|
|
document.getElementById('zoom-slider').value = 1.0;
|
|
initCanvasSize();
|
|
centerRulerAbsolute();
|
|
}
|
|
|
|
function adjustZoom(delta) {
|
|
let newZoom = Math.max(0.5, Math.min(3.0, state.zoom + delta));
|
|
setZoom(newZoom);
|
|
}
|
|
|
|
function setZoom(val) {
|
|
state.zoom = val;
|
|
document.getElementById('zoom-slider').value = val;
|
|
document.getElementById('zoom-val').innerText = `${Math.round(val * 100)}%`;
|
|
initCanvasSize();
|
|
}
|
|
|
|
document.getElementById('zoom-slider').addEventListener('input', (e) => {
|
|
setZoom(parseFloat(e.target.value));
|
|
});
|
|
|
|
function updateScaleStatusUI() {
|
|
const st = document.getElementById('scale-status');
|
|
if (state.pixelsPerNauticalMileBase !== null) {
|
|
st.innerText = "Escala: Calibrada OK (5 Millas configuradas)";
|
|
st.className = "text-[10px] text-emerald-400 font-mono mt-1";
|
|
} else {
|
|
st.innerText = "Escala: No calibrada (Usar botón Marcar Escala)";
|
|
st.className = "text-[10px] text-cyan-400 font-mono mt-1";
|
|
}
|
|
}
|
|
|
|
function drawChart() {
|
|
if (state.customChartImg) {
|
|
ctx.drawImage(state.customChartImg, 0, 0, canvas.width, canvas.height);
|
|
} else {
|
|
ctx.fillStyle = '#cbe0f0';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
ctx.fillStyle = '#e5d9a5';
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0);
|
|
ctx.lineTo(canvas.width * 0.4, 0);
|
|
ctx.bezierCurveTo(canvas.width * 0.3, canvas.height * 0.3, canvas.width * 0.1, canvas.height * 0.5, 0, canvas.height * 0.7);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#b5a469';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = '#1e293b';
|
|
ctx.font = 'bold 14px sans-serif';
|
|
ctx.fillText('CARTA NÁUTICA DE REFERENCIA (ESTRECHO DE GIBRALTAR)', 40, 50);
|
|
|
|
ctx.strokeStyle = '#64748b';
|
|
ctx.lineWidth = 0.8;
|
|
ctx.setLineDash([4, 4]);
|
|
const step = 120 * state.zoom;
|
|
for (let x = step; x < canvas.width; x += step) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, 0);
|
|
ctx.lineTo(x, canvas.height);
|
|
ctx.stroke();
|
|
}
|
|
for (let y = step; y < canvas.height; y += step) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(canvas.width, y);
|
|
ctx.stroke();
|
|
}
|
|
ctx.setLineDash([]);
|
|
}
|
|
|
|
// Dibujar puntos y líneas de calibración de escala si existen
|
|
if (state.scalePoints.length > 0) {
|
|
state.scalePoints.forEach((sp, idx) => {
|
|
let currX = sp.x * state.zoom;
|
|
let currY = sp.y * state.zoom;
|
|
ctx.fillStyle = '#06b6d4';
|
|
ctx.beginPath();
|
|
ctx.arc(currX, currY, 6, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#ffffff';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = '#06b6d4';
|
|
ctx.font = 'bold 11px sans-serif';
|
|
ctx.fillText(`Escala P${idx} (${idx === 0 ? '0M' : '5M'})`, currX + 10, currY + 4);
|
|
});
|
|
|
|
if (state.scalePoints.length === 2) {
|
|
let p0 = { x: state.scalePoints[0].x * state.zoom, y: state.scalePoints[0].y * state.zoom };
|
|
let p1 = { x: state.scalePoints[1].x * state.zoom, y: state.scalePoints[1].y * state.zoom };
|
|
ctx.strokeStyle = '#06b6d4';
|
|
ctx.lineWidth = 2;
|
|
ctx.setLineDash([3, 3]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(p0.x, p0.y);
|
|
ctx.lineTo(p1.x, p1.y);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
}
|
|
}
|
|
|
|
// Dibujar líneas de ruta encadenadas (A -> B -> C -> ...) y sus distancias
|
|
if (state.points.length > 1) {
|
|
ctx.strokeStyle = '#f59e0b';
|
|
ctx.lineWidth = 3;
|
|
ctx.setLineDash([6, 4]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(state.points[0].x, state.points[0].y);
|
|
for (let i = 1; i < state.points.length; i++) {
|
|
ctx.lineTo(state.points[i].x, state.points[i].y);
|
|
}
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
|
|
// Escribir la distancia en millas náuticas sobre cada segmento de ruta si la escala está calibrada
|
|
if (state.pixelsPerNauticalMileBase !== null) {
|
|
let currentPixelsPerNM = state.pixelsPerNauticalMileBase * state.zoom;
|
|
for (let i = 0; i < state.points.length - 1; i++) {
|
|
let pA = state.points[i];
|
|
let pB = state.points[i + 1];
|
|
let distPixels = Math.hypot(pB.x - pA.x, pB.y - pA.y);
|
|
let distNM = distPixels / currentPixelsPerNM;
|
|
|
|
let midX = (pA.x + pB.x) / 2;
|
|
let midY = (pA.y + pB.y) / 2;
|
|
|
|
ctx.save();
|
|
ctx.translate(midX, midY);
|
|
ctx.fillStyle = 'rgba(15, 23, 42, 0.85)';
|
|
ctx.strokeStyle = '#f59e0b';
|
|
ctx.lineWidth = 1;
|
|
let text = `${distNM.toFixed(2)} M`;
|
|
ctx.font = 'bold 11px monospace';
|
|
let textWidth = ctx.measureText(text).width;
|
|
|
|
ctx.fillRect(-textWidth / 2 - 6, -10, textWidth + 12, 20);
|
|
ctx.strokeRect(-textWidth / 2 - 6, -10, textWidth + 12, 20);
|
|
|
|
ctx.fillStyle = '#f59e0b';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(text, 0, 0);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Dibujar marcadores de puntos de ruta
|
|
state.points.forEach(p => {
|
|
ctx.fillStyle = '#ef4444';
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, 6, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#ffffff';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = '#0f172a';
|
|
ctx.font = 'bold 11px sans-serif';
|
|
ctx.fillText(`Punto ${p.label}`, p.x + 10, p.y + 4);
|
|
});
|
|
}
|
|
|
|
function drawPlotter() {
|
|
ctx.save();
|
|
ctx.translate(state.rulerX, state.rulerY);
|
|
ctx.rotate((state.rulerAngle * Math.PI) / 180);
|
|
|
|
const rw = 860;
|
|
const rh = 160;
|
|
const rx = -rw / 2;
|
|
const ry = -rh / 2;
|
|
const radius = 68;
|
|
|
|
// Cuerpo translúcido de la regla principal
|
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.45)';
|
|
ctx.strokeStyle = 'rgba(71, 85, 105, 0.8)';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.roundRect(rx, ry, rw, rh, 10);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(rx + 2, ry + 2, rw - 4, rh - 4);
|
|
|
|
// Gran flecha azul "DIRECTION OF BEARING"
|
|
ctx.fillStyle = 'rgba(2, 132, 199, 0.85)';
|
|
ctx.beginPath();
|
|
ctx.moveTo(rx + rw - 40, 0);
|
|
ctx.lineTo(rx + rw - 100, -35);
|
|
ctx.lineTo(rx + rw - 100, 35);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.font = 'bold 10px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('DIRECTION OF BEARING', rx + rw - 140, 4);
|
|
|
|
// --- CUADRÍCULA DE LA REGLA FIJA ---
|
|
ctx.strokeStyle = 'rgba(15, 23, 42, 0.35)';
|
|
ctx.lineWidth = 0.8;
|
|
const gridStep = 20;
|
|
|
|
for (let x = rx + 30; x <= rx + rw - 30; x += gridStep) {
|
|
if (Math.abs(x) >= radius + 2) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, ry + 15);
|
|
ctx.lineTo(x, ry + rh - 15);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
for (let y = ry + 15; y <= ry + rh - 15; y += gridStep) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(rx + 30, y);
|
|
ctx.lineTo(-radius - 2, y);
|
|
ctx.stroke();
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(radius + 2, y);
|
|
ctx.lineTo(rx + rw - 30, y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.fillStyle = '#0f172a';
|
|
ctx.font = '8px monospace';
|
|
ctx.textAlign = 'center';
|
|
for (let i = 0; i <= 80; i++) {
|
|
const xMark = rx + 30 + i * 10;
|
|
if (xMark < rx + rw - 30) {
|
|
const hMark = (i % 5 === 0) ? 10 : 5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(xMark, ry);
|
|
ctx.lineTo(xMark, ry + hMark);
|
|
ctx.moveTo(xMark, ry + rh);
|
|
ctx.lineTo(xMark, ry + rh - hMark);
|
|
ctx.stroke();
|
|
|
|
if (i % 5 === 0) {
|
|
ctx.fillText(i, xMark, ry + 18);
|
|
ctx.fillText(i, xMark, ry + rh - 12);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- DISCO CENTRAL GIRATORIO ---
|
|
ctx.save();
|
|
const diskAngleRad = (state.diskAngle * Math.PI) / 180;
|
|
ctx.rotate(diskAngleRad);
|
|
|
|
ctx.strokeStyle = '#0f172a';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, radius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
ctx.strokeStyle = 'rgba(15, 23, 42, 0.4)';
|
|
ctx.lineWidth = 0.8;
|
|
const innerStep = 14;
|
|
for (let x = -radius + 14; x <= radius - 14; x += innerStep) {
|
|
const limitY = Math.sqrt(Math.max(0, radius * radius - x * x));
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, -limitY);
|
|
ctx.lineTo(x, limitY);
|
|
ctx.stroke();
|
|
}
|
|
for (let y = -radius + 14; y <= radius - 14; y += innerStep) {
|
|
const limitX = Math.sqrt(Math.max(0, radius * radius - y * y));
|
|
ctx.beginPath();
|
|
ctx.moveTo(-limitX, y);
|
|
ctx.lineTo(limitX, y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
for (let deg = 0; deg < 360; deg += 2) {
|
|
const rad = (deg * Math.PI) / 180;
|
|
const isMajor = deg % 10 === 0;
|
|
const isMedium = deg % 5 === 0;
|
|
const len = isMajor ? 10 : (isMedium ? 7 : 4);
|
|
|
|
const x1 = Math.sin(rad) * radius;
|
|
const y1 = -Math.cos(rad) * radius;
|
|
const x2 = Math.sin(rad) * (radius - len);
|
|
const y2 = -Math.cos(rad) * (radius - len);
|
|
|
|
ctx.strokeStyle = isMajor ? '#0f172a' : '#475569';
|
|
ctx.lineWidth = isMajor ? 1.5 : 0.8;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x1, y1);
|
|
ctx.lineTo(x2, y2);
|
|
ctx.stroke();
|
|
|
|
if (isMajor) {
|
|
const tx = Math.sin(rad) * (radius - 18);
|
|
const ty = -Math.cos(rad) * (radius - 18);
|
|
ctx.fillStyle = '#0f172a';
|
|
ctx.font = 'bold 8px monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(deg, tx, ty);
|
|
}
|
|
}
|
|
|
|
ctx.fillStyle = '#ef4444';
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -radius + 12);
|
|
ctx.lineTo(-7, -radius + 24);
|
|
ctx.lineTo(7, -radius + 24);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
ctx.restore();
|
|
|
|
ctx.fillStyle = '#f59e0b';
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, 7, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#0f172a';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
ctx.fillStyle = '#0f172a';
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, 2, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.save();
|
|
const markerX = radius;
|
|
ctx.fillStyle = '#000000';
|
|
ctx.beginPath();
|
|
ctx.moveTo(markerX, 0);
|
|
ctx.lineTo(markerX + 8, -4);
|
|
ctx.lineTo(markerX + 8, 4);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
function draw() {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
drawChart();
|
|
drawPlotter();
|
|
updateReadouts();
|
|
}
|
|
|
|
function updateReadouts() {
|
|
let baseReferenceAngle = (360 - state.rulerAngle) % 360;
|
|
let rv = (state.rulerAngle + 90 - (state.diskAngle - baseReferenceAngle)) % 360;
|
|
if (rv < 0) rv += 360;
|
|
|
|
let rm = (rv - state.totalError) % 360;
|
|
if (rm < 0) rm += 360;
|
|
|
|
document.getElementById('readout-rv').innerText = `${Math.round(rv).toString().padStart(3, '0')}°`;
|
|
document.getElementById('readout-err').innerText = `${Math.abs(state.totalError)}° ${state.totalError < 0 ? 'W' : 'E'}`;
|
|
document.getElementById('readout-rm').innerText = `${Math.round(rm).toString().padStart(3, '0')}°`;
|
|
|
|
document.getElementById('disk-angle-val').innerText = `${Math.round(state.diskAngle).toString().padStart(3, '0')}°`;
|
|
document.getElementById('ruler-angle-val').innerText = `${Math.round(state.rulerAngle).toString().padStart(3, '0')}°`;
|
|
document.getElementById('error-val').innerText = `${Math.abs(state.totalError)}° ${state.totalError < 0 ? 'W' : 'E'}`;
|
|
}
|
|
|
|
function toggleScaleMode() {
|
|
const btn = document.getElementById('btn-mode-scale');
|
|
const badge = document.getElementById('status-badge');
|
|
const statusText = document.getElementById('status-text');
|
|
|
|
if (state.mode !== 'SCALE_P0' && state.mode !== 'SCALE_P1') {
|
|
state.mode = 'SCALE_P0';
|
|
state.scalePoints = [];
|
|
canvas.className = "cursor-crosshair-custom block m-auto";
|
|
btn.className = "bg-rose-600 hover:bg-rose-500 text-white font-bold px-3 py-1 rounded-lg text-xs transition-all flex items-center gap-1.5 shadow-md";
|
|
document.getElementById('btn-scale-text').innerText = "Cancelar Escala";
|
|
badge.classList.remove('hidden');
|
|
statusText.innerText = "Haz clic en el Punto 0 (origen de 5 millas en escala de latitud)";
|
|
} else {
|
|
resetToMoveMode();
|
|
}
|
|
draw();
|
|
}
|
|
|
|
function toggleRouteMode() {
|
|
const btn = document.getElementById('btn-mode-ab');
|
|
const badge = document.getElementById('status-badge');
|
|
const statusText = document.getElementById('status-text');
|
|
|
|
if (state.mode === 'MOVE') {
|
|
state.mode = 'ADD_POINTS';
|
|
canvas.className = "cursor-crosshair-custom block m-auto";
|
|
btn.className = "bg-rose-500 hover:bg-rose-400 text-slate-950 font-bold px-3.5 py-1 rounded-lg text-xs transition-all flex items-center gap-2 shadow-md";
|
|
document.getElementById('btn-ab-text').innerText = "Finalizar Puntos";
|
|
badge.classList.remove('hidden');
|
|
statusText.innerText = "Haz clic en la carta para añadir puntos (A -> B -> C...)";
|
|
} else {
|
|
resetToMoveMode();
|
|
}
|
|
draw();
|
|
}
|
|
|
|
function resetToMoveMode() {
|
|
state.mode = 'MOVE';
|
|
canvas.className = "cursor-grab-custom block m-auto";
|
|
const btnAb = document.getElementById('btn-mode-ab');
|
|
btnAb.className = "bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold px-3.5 py-1 rounded-lg text-xs transition-all flex items-center gap-2 shadow-md";
|
|
document.getElementById('btn-ab-text').innerText = "Añadir Puntos Ruta";
|
|
|
|
const btnScale = document.getElementById('btn-mode-scale');
|
|
btnScale.className = "bg-cyan-600 hover:bg-cyan-500 text-white font-bold px-3 py-1 rounded-lg text-xs transition-all flex items-center gap-1.5 shadow-md";
|
|
document.getElementById('btn-scale-text').innerText = "Marcar Escala (5M)";
|
|
|
|
document.getElementById('status-badge').classList.add('hidden');
|
|
}
|
|
|
|
function clearAllRoutes() {
|
|
state.points = [];
|
|
resetToMoveMode();
|
|
draw();
|
|
}
|
|
|
|
function handleInteractionStart(clientX, clientY) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const clickX = clientX - rect.left;
|
|
const clickY = clientY - rect.top;
|
|
|
|
if (state.mode === 'SCALE_P0') {
|
|
state.scalePoints = [{ x: clickX / state.zoom, y: clickY / state.zoom }];
|
|
state.mode = 'SCALE_P1';
|
|
document.getElementById('status-text').innerText = "Haz clic en el Punto 1 (a 5 millas náuticas exactas del Punto 0)";
|
|
draw();
|
|
return true;
|
|
} else if (state.mode === 'SCALE_P1') {
|
|
state.scalePoints.push({ x: clickX / state.zoom, y: clickY / state.zoom });
|
|
// Calcular píxeles por milla a zoom 1.0
|
|
let p0 = state.scalePoints[0];
|
|
let p1 = state.scalePoints[1];
|
|
let distPixelsBase = Math.hypot(p1.x - p0.x, p1.y - p0.y);
|
|
state.pixelsPerNauticalMileBase = distPixelsBase / 5.0; // 5 millas náuticas
|
|
updateScaleStatusUI();
|
|
resetToMoveMode();
|
|
draw();
|
|
return true;
|
|
}
|
|
|
|
if (state.mode === 'ADD_POINTS') {
|
|
const nextChar = String.fromCharCode(65 + state.points.length);
|
|
state.points.push({ x: clickX, y: clickY, label: nextChar });
|
|
|
|
if (state.points.length === 2) {
|
|
const pA = state.points[0];
|
|
const pB = state.points[1];
|
|
const dx = pB.x - pA.x;
|
|
const dy = pB.y - pA.y;
|
|
|
|
let angleRad = Math.atan2(dy, dx);
|
|
let angleDeg = (angleRad * 180) / Math.PI;
|
|
if (angleDeg < 0) angleDeg += 360;
|
|
|
|
state.rulerAngle = Math.round(angleDeg);
|
|
state.diskAngle = (360 - state.rulerAngle) % 360;
|
|
|
|
document.getElementById('ruler-angle-slider').value = state.rulerAngle;
|
|
document.getElementById('disk-angle-slider').value = state.diskAngle;
|
|
|
|
state.rulerX = (pA.x + pB.x) / 2;
|
|
state.rulerY = (pA.y + pB.y) / 2;
|
|
}
|
|
|
|
draw();
|
|
return true;
|
|
}
|
|
|
|
const dist = Math.hypot(clickX - state.rulerX, clickY - state.rulerY);
|
|
if (dist < 300) {
|
|
state.isDragging = true;
|
|
state.dragStartX = clickX - state.rulerX;
|
|
state.dragStartY = clickY - state.rulerY;
|
|
canvas.classList.remove('cursor-grab-custom');
|
|
canvas.classList.add('cursor-grabbing');
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function handleInteractionMove(clientX, clientY) {
|
|
if (state.isDragging && state.mode === 'MOVE') {
|
|
const rect = canvas.getBoundingClientRect();
|
|
state.rulerX = (clientX - rect.left) - state.dragStartX;
|
|
state.rulerY = (clientY - rect.top) - state.dragStartY;
|
|
draw();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function handleInteractionEnd() {
|
|
if (state.isDragging) {
|
|
state.isDragging = false;
|
|
canvas.classList.remove('cursor-grabbing');
|
|
canvas.classList.add('cursor-grab-custom');
|
|
}
|
|
}
|
|
|
|
canvas.addEventListener('mousedown', (e) => {
|
|
handleInteractionStart(e.clientX, e.clientY);
|
|
});
|
|
|
|
canvas.addEventListener('mousemove', (e) => {
|
|
handleInteractionMove(e.clientX, e.clientY);
|
|
});
|
|
|
|
window.addEventListener('mouseup', () => {
|
|
handleInteractionEnd();
|
|
});
|
|
|
|
canvas.addEventListener('touchstart', (e) => {
|
|
if (e.touches.length === 1) {
|
|
const touch = e.touches[0];
|
|
const captured = handleInteractionStart(touch.clientX, touch.clientY);
|
|
if (captured || state.mode !== 'MOVE') {
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
}, { passive: false });
|
|
|
|
canvas.addEventListener('touchmove', (e) => {
|
|
if (state.isDragging && e.touches.length === 1) {
|
|
const touch = e.touches[0];
|
|
handleInteractionMove(touch.clientX, touch.clientY);
|
|
e.preventDefault();
|
|
}
|
|
}, { passive: false });
|
|
|
|
canvas.addEventListener('touchend', () => {
|
|
handleInteractionEnd();
|
|
});
|
|
|
|
document.getElementById('disk-angle-slider').addEventListener('input', (e) => {
|
|
state.diskAngle = parseFloat(e.target.value);
|
|
draw();
|
|
});
|
|
|
|
document.getElementById('ruler-angle-slider').addEventListener('input', (e) => {
|
|
state.rulerAngle = parseFloat(e.target.value);
|
|
draw();
|
|
});
|
|
|
|
document.getElementById('error-slider').addEventListener('input', (e) => {
|
|
state.totalError = parseFloat(e.target.value);
|
|
draw();
|
|
});
|
|
|
|
window.addEventListener('load', () => {
|
|
initCanvasSize();
|
|
});
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|