incluimos version tablet
This commit is contained in:
1 parent
c88107f114
commit
900f8d3371
1 file changed
+62
-13
@@ -0,0 +1,743 @@
|
||||
<!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</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; }
|
||||
/* Previene el scroll por defecto del navegador al tocar el Canvas */
|
||||
#plotterCanvas {
|
||||
touch-action: none;
|
||||
}
|
||||
</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 Rutas Múltiples & Guía</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 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>
|
||||
|
||||
<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. Medir o Trazar un 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. Asegúrate de que la <strong>Gran Flecha Azul</strong> apunte 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> 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>
|
||||
<p><strong>2. Corrección Total (Ct) y Rumbo de Aguja (Ra):</strong></p>
|
||||
<ul class="list-disc list-inside space-y-1 text-slate-400 pl-2">
|
||||
<li>Ajusta la corrección total Ct = (±dm) + (±Δ) en el deslizador lateral.</li>
|
||||
<li>Obtén el Rumbo Magnético o de Aguja (Rm/Ra) aplicando la fórmula clásica de corrección.</li>
|
||||
</ul>
|
||||
<p><strong>3. Trazado de Rutas Múltiples (A ➞ B ➞ C ➞ ...):</strong></p>
|
||||
<ul class="list-disc list-inside space-y-1 text-slate-400 pl-2">
|
||||
<li>Haz clic en <strong>"Añadir Puntos Ruta"</strong> para marcar secuencialmente los waypoints en la carta. El primer tramo (A ➞ B) orientará la regla automáticamente; para los siguientes tramos (B ➞ C ➞ ...), el usuario debe orientar y mover la regla manualmente sobre la línea trazada.</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',
|
||||
points: [],
|
||||
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;
|
||||
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;
|
||||
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 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 líneas de ruta encadenadas (A -> B -> C -> ...)
|
||||
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([]);
|
||||
}
|
||||
|
||||
// Dibujar marcadores de puntos
|
||||
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" (hacia la derecha en el sistema local)
|
||||
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;
|
||||
|
||||
// Líneas Verticales (omitiendo el círculo central)
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// Líneas Horizontales (omitiendo el círculo central)
|
||||
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();
|
||||
}
|
||||
|
||||
// Marcas milimetradas/graduadas en los bordes superiores e inferiores
|
||||
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 (TOTALMENTE TRANSPARENTE) ---
|
||||
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();
|
||||
|
||||
// Cuadrícula propia del disco giratorio
|
||||
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();
|
||||
}
|
||||
|
||||
// Marcas de grados del transportador
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// TRIÁNGULO ROJO EN EL NORTE (0º) DEL DISCO
|
||||
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(); // Fin de rotación del disco
|
||||
|
||||
// Perno central de la brújula
|
||||
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();
|
||||
|
||||
// --- MARCA DE LECTURA FIJA (Índice triangular negro a la derecha) ---
|
||||
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(); // Fin de la regla principal
|
||||
}
|
||||
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawChart();
|
||||
drawPlotter();
|
||||
updateReadouts();
|
||||
}
|
||||
|
||||
function updateReadouts() {
|
||||
// Cálculo preciso unificado del Rumbo Verdadero (Rv) considerando la diferencia entre regla y brújula
|
||||
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 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 para añadir puntos en la carta (A -> B -> C...)";
|
||||
} else {
|
||||
resetToMoveMode();
|
||||
}
|
||||
draw();
|
||||
}
|
||||
|
||||
function resetToMoveMode() {
|
||||
state.mode = 'MOVE';
|
||||
canvas.className = "cursor-grab-custom block m-auto";
|
||||
const btn = document.getElementById('btn-mode-ab');
|
||||
btn.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";
|
||||
document.getElementById('status-badge').classList.add('hidden');
|
||||
}
|
||||
|
||||
function clearAllRoutes() {
|
||||
state.points = [];
|
||||
resetToMoveMode();
|
||||
draw();
|
||||
}
|
||||
|
||||
// Lógica compartida para inicio de interacción (Mouse / Touch)
|
||||
function handlePointerStart(clientX, clientY) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const clickX = clientX - rect.left;
|
||||
const clickY = clientY - rect.top;
|
||||
|
||||
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 false;
|
||||
}
|
||||
|
||||
const dist = Math.hypot(clickX - state.rulerX, clickY - state.rulerY);
|
||||
if (dist < 250) {
|
||||
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; // Se inició el arrastre
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Lógica compartida para movimiento (Mouse / Touch)
|
||||
function handlePointerMove(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();
|
||||
}
|
||||
}
|
||||
|
||||
// Lógica compartida para fin de interacción
|
||||
function handlePointerEnd() {
|
||||
if (state.isDragging) {
|
||||
state.isDragging = false;
|
||||
canvas.classList.remove('cursor-grabbing');
|
||||
canvas.classList.add('cursor-grab-custom');
|
||||
}
|
||||
}
|
||||
|
||||
// --- MANEJADORES EVENTOS MOUSE ---
|
||||
canvas.addEventListener('mousedown', (e) => {
|
||||
handlePointerStart(e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
handlePointerMove(e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', () => {
|
||||
handlePointerEnd();
|
||||
});
|
||||
|
||||
// --- MANEJADORES EVENTOS TOUCH (TABLETS / MÓVILES) ---
|
||||
canvas.addEventListener('touchstart', (e) => {
|
||||
if (e.touches.length === 1) {
|
||||
const touch = e.touches[0];
|
||||
const isDraggingRuler = handlePointerStart(touch.clientX, touch.clientY);
|
||||
// Prevenir el scroll del mapa si se está tocando la regla o agregando puntos
|
||||
if (isDraggingRuler || state.mode === 'ADD_POINTS') {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
canvas.addEventListener('touchmove', (e) => {
|
||||
if (state.isDragging && e.touches.length === 1) {
|
||||
e.preventDefault(); // Prevenir desplazamiento de pantalla mientras se arrastra la regla
|
||||
const touch = e.touches[0];
|
||||
handlePointerMove(touch.clientX, touch.clientY);
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
window.addEventListener('touchend', () => {
|
||||
handlePointerEnd();
|
||||
});
|
||||
|
||||
window.addEventListener('touchcancel', () => {
|
||||
handlePointerEnd();
|
||||
});
|
||||
|
||||
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>
|
||||
Reference in new issue
Block a user