Files

905 lines
35 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 Lectura de Coordenadas</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 Coordenadas (l, L) & 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>1. Lectura Directa de Coordenadas (l, L):</strong></p>
<ul class="list-disc list-inside space-y-1 text-slate-400 pl-2">
<li>Cada punto marcado ($A, B, C \dots$) proyecta automáticamente dos líneas finas de color continuo hasta los bordes del canvas.</li>
<li>Sigue la <strong>línea horizontal</strong> hacia el margen izquierdo/derecho para leer la Latitud ($l$) exacta.</li>
<li>Sigue la <strong>línea vertical</strong> hacia el margen superior/inferior para leer la Longitud ($L$) exacta.</li>
</ul>
<p><strong>2. 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>Haz clic en el Punto 0 y luego en el Punto 1 a <strong>5 millas náuticas</strong> en la escala de latitud.</li>
</ul>
<p><strong>3. Medir o Trazar Rumbos y Rutas:</strong></p>
<ul class="list-disc list-inside space-y-1 text-slate-400 pl-2">
<li>Con el botón <strong>"Añadir Puntos Ruta"</strong> marca tus waypoints ($A \rightarrow B \rightarrow C$). Se calculará automáticamente el Rumbo Verdadero ($Rv$) y la distancia entre puntos.</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: [],
pixelsPerNauticalMileBase: null,
customChartImg: null,
baseWidth: 1400,
baseHeight: 900
};
// Paleta de colores vivos distintos para identificar las coordenadas (l, L) de cada punto
const pointColors = [
'#ef4444', // Rojo (A)
'#3b82f6', // Azul (B)
'#10b981', // Verde (C)
'#a855f7', // Púrpura (D)
'#ec4899', // Rosa (E)
'#f97316', // Naranja (F)
'#06b6d4', // Cian (G)
'#84cc16' // Lima (H)
];
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;
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
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([]);
}
}
// --- LÍNEAS FINAS DE COORDENADAS (LATITUD Y LONGITUD) PARA CADA PUNTO ---
state.points.forEach((p, idx) => {
const color = pointColors[idx % pointColors.length];
const px = p.x * state.zoom;
const py = p.y * state.zoom;
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]); // Líneas discontinuas finas y elegantes
// Línea Horizontal (Latitud - l) a través de todo el Canvas
ctx.beginPath();
ctx.moveTo(0, py);
ctx.lineTo(canvas.width, py);
ctx.stroke();
// Línea Vertical (Longitud - L) a través de todo el Canvas
ctx.beginPath();
ctx.moveTo(px, 0);
ctx.lineTo(px, canvas.height);
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.zoom, state.points[0].y * state.zoom);
for (let i = 1; i < state.points.length; i++) {
ctx.lineTo(state.points[i].x * state.zoom, state.points[i].y * state.zoom);
}
ctx.stroke();
ctx.setLineDash([]);
// Escribir la distancia en millas náuticas sobre cada segmento
if (state.pixelsPerNauticalMileBase !== null) {
for (let i = 0; i < state.points.length - 1; i++) {
let pA = state.points[i];
let pB = state.points[i + 1];
// Distancia en coordenadas base
let distPixelsBase = Math.hypot(pB.x - pA.x, pB.y - pA.y);
let distNM = distPixelsBase / state.pixelsPerNauticalMileBase;
let midX = ((pA.x + pB.x) / 2) * state.zoom;
let midY = ((pA.y + pB.y) / 2) * state.zoom;
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 con el color correspondiente
state.points.forEach((p, idx) => {
const color = pointColors[idx % pointColors.length];
const px = p.x * state.zoom;
const py = p.y * state.zoom;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(px, py, 6, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.stroke();
// Fondo para mejor legibilidad del nombre del punto
ctx.fillStyle = 'rgba(15, 23, 42, 0.75)';
ctx.fillRect(px + 8, py - 12, 55, 16);
ctx.fillStyle = color;
ctx.font = 'bold 11px sans-serif';
ctx.fillText(`Punto ${p.label}`, px + 12, py);
});
}
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 });
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;
updateScaleStatusUI();
resetToMoveMode();
draw();
return true;
}
if (state.mode === 'ADD_POINTS') {
const nextChar = String.fromCharCode(65 + state.points.length);
// Almacenar coordenadas base (independientes del zoom)
const baseX = clickX / state.zoom;
const baseY = clickY / state.zoom;
state.points.push({ x: baseX, y: baseY, 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.zoom;
state.rulerY = ((pA.y + pB.y) / 2) * state.zoom;
}
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>