DroneSimulator / index.html
EHRley's picture
Update index.html
992a218 verified
Raw
History Blame Contribute Delete
24.6 kB
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>A Simulador de Vuelo de Drone</title>
<style>
:root {
/* Fondos: Morados oscuros y profundos */
--bg: #0e051e; /* Morado-negro extremadamente oscuro */
--panel: rgba(28, 14, 46, 0.9); /* Morado oscuro para los paneles grandes */
--panel-2: rgba(43, 23, 67, 0.85); /* Un morado ligeramente más claro para contraste */
--line: rgba(188, 148, 254, 0.1); /* Líneas de cuadrícula sutiles en lavanda */
/* Texto */
--text: #f5f0ff; /* Blanco lila, muy brillante y limpio */
--muted: #a794c4; /* Texto secundario en lavanda apagado */
/* Colores de Acento Principales (El alma del cambio) */
--purple-bright: #bc00dd; /* Un morado neón muy saturado y brillante */
--yellow-led: #fff01f; /* Amarillo neón eléctrico tipo LED */
/* Reemplazos de los colores de acento originales de la imagen */
/* (Cian original se vuelve Amarillo LED, Azul original se vuelve Morado Brillante) */
--cyan: var(--yellow-led); /* El amarillo LED reemplaza al cian */
--blue: var(--purple-bright); /* El morado brillante reemplaza al azul */
--green: #ffe600; /* Un amarillo ámbar más cálido (reemplaza al verde de estado) */
--orange: #ffb700; /* Naranja-amarillo intenso */
--danger: #ff0055; /* Rosa fucsia intenso */
/* Sombras y efectos */
--shadow: 0 24px 60px rgba(9, 3, 20, 0.6); /* Sombra profunda con tinte morado */
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background:
radial-gradient(circle at 15% 10%, rgba(67, 216, 255, 0.16), transparent 27%),
radial-gradient(circle at 90% 12%, rgba(79, 121, 255, 0.18), transparent 25%),
linear-gradient(145deg, #050b14, #091626 50%, #06101d);
overflow-x: hidden;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px);
background-size: 44px 44px;
mask-image: linear-gradient(to bottom, black, transparent 85%);
}
.app {
width: min(1440px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 36px;
position: relative;
z-index: 1;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 22px;
}
.brand { display: flex; align-items: center; gap: 14px; }
.logo {
width: 48px;
height: 48px;
border-radius: 15px;
display: grid;
place-items: center;
background: linear-gradient(145deg, rgba(67,216,255,.24), rgba(79,121,255,.18));
border: 1px solid rgba(116, 224, 255, 0.28);
box-shadow: inset 0 1px rgba(255,255,255,.16), 0 12px 30px rgba(43, 167, 255, .16);
}
.logo svg { width: 27px; height: 27px; }
h1 { margin: 0; font-size: clamp(1.35rem, 2vw, 2rem); letter-spacing: -0.04em; }
.subtitle { margin: 3px 0 0; color: var(--muted); font-size: .92rem; }
.status {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 9px 13px;
border-radius: 999px;
border: 1px solid rgba(108, 242, 178, .24);
background: rgba(108, 242, 178, .07);
color: #bafbd9;
font-size: .82rem;
white-space: nowrap;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 0 5px rgba(108, 242, 178, .09), 0 0 14px var(--green);
}
.dashboard {
display: grid;
grid-template-columns: 330px minmax(0, 1fr);
gap: 18px;
}
.panel {
border: 1px solid var(--line);
background: linear-gradient(180deg, rgba(18, 34, 56, .92), rgba(10, 23, 39, .88));
box-shadow: var(--shadow);
backdrop-filter: blur(20px);
border-radius: 22px;
}
.controls { padding: 22px; }
.panel-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
}
.panel-title h2 { margin: 0; font-size: 1rem; letter-spacing: -.02em; }
.tiny { color: var(--muted); font-size: .73rem; text-transform: uppercase; letter-spacing: .12em; }
.control-group {
padding: 16px 0;
border-bottom: 1px solid var(--line);
}
.control-group:first-of-type { padding-top: 6px; }
.control-group:last-of-type { border-bottom: 0; }
.control-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
label { color: #dbe9f7; font-size: .88rem; font-weight: 650; }
.value-box {
min-width: 82px;
padding: 6px 9px;
text-align: right;
border-radius: 9px;
color: var(--cyan);
background: rgba(67,216,255,.08);
border: 1px solid rgba(67,216,255,.14);
font: 700 .84rem ui-monospace, SFMono-Regular, Menlo, monospace;
}
input[type="range"] {
width: 100%;
height: 5px;
appearance: none;
border-radius: 99px;
outline: none;
background: linear-gradient(90deg, var(--cyan) 0 var(--fill, 50%), rgba(148,163,184,.22) var(--fill, 50%));
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 17px;
height: 17px;
border-radius: 50%;
background: #ecfbff;
border: 4px solid var(--blue);
box-shadow: 0 0 0 5px rgba(79,121,255,.13), 0 3px 9px rgba(0,0,0,.35);
cursor: pointer;
}
.range-labels { display: flex; justify-content: space-between; margin-top: 8px; color: #667d94; font-size: .68rem; }
.physics-card {
margin-top: 16px;
padding: 14px;
border-radius: 14px;
background: rgba(5, 13, 24, .42);
border: 1px solid var(--line);
}
.physics-row { display: flex; justify-content: space-between; padding: 5px 0; color: var(--muted); font-size: .78rem; }
.physics-row strong { color: #dceafa; font-family: ui-monospace, monospace; }
.actions { display: grid; grid-template-columns: 1fr auto; gap: 10px; margin-top: 18px; }
button {
border: 0;
border-radius: 12px;
padding: 12px 14px;
font: 700 .88rem inherit;
cursor: pointer;
transition: transform .18s ease, filter .18s ease, border-color .18s ease;
}
button:hover { transform: translateY(-1px); filter: brightness(1.08); }
button:active { transform: translateY(0); }
.primary {
color: #04131e;
background: linear-gradient(135deg, var(--cyan), #72f1cf);
box-shadow: 0 10px 24px rgba(67,216,255,.18);
}
.secondary {
color: #cce0f3;
background: rgba(255,255,255,.055);
border: 1px solid var(--line);
width: 44px;
}
.main { display: grid; grid-template-rows: auto 1fr; gap: 18px; min-width: 0; }
.metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.metric { padding: 17px 18px; min-width: 0; }
.metric-label { color: var(--muted); font-size: .75rem; margin-bottom: 8px; }
.metric-value { font-size: clamp(1.15rem, 2vw, 1.65rem); font-weight: 760; letter-spacing: -.04em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.metric-unit { color: #70879d; font-size: .72rem; margin-left: 4px; font-weight: 600; }
.metric-note { margin-top: 7px; font-size: .69rem; color: #61768c; }
.chart-panel { padding: 18px; min-height: 540px; display: flex; flex-direction: column; }
.chart-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 2px 4px 15px;
}
.legend { display: flex; align-items: center; gap: 9px; color: var(--muted); font-size: .75rem; }
.legend-line { width: 28px; height: 3px; border-radius: 9px; background: linear-gradient(90deg, var(--cyan), var(--green)); box-shadow: 0 0 8px rgba(67,216,255,.65); }
.canvas-wrap {
position: relative;
flex: 1;
min-height: 440px;
overflow: hidden;
border-radius: 16px;
border: 1px solid rgba(148,163,184,.13);
background:
radial-gradient(circle at 50% 0%, rgba(52, 117, 174, .12), transparent 40%),
linear-gradient(180deg, rgba(5, 13, 24, .62), rgba(4, 12, 22, .82));
}
canvas { width: 100%; height: 100%; display: block; }
.chart-badge {
position: absolute;
right: 14px;
top: 14px;
padding: 8px 10px;
border-radius: 10px;
background: rgba(4,12,22,.72);
border: 1px solid var(--line);
color: var(--muted);
font-size: .72rem;
backdrop-filter: blur(10px);
}
.footer-note { color: #5f748a; font-size: .72rem; margin: 13px 4px 0; line-height: 1.5; }
@media (max-width: 980px) {
.dashboard { grid-template-columns: 1fr; }
.controls { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.panel-title, .physics-card, .actions { grid-column: 1 / -1; }
.control-group { border-bottom: 0; padding: 6px 0; }
}
@media (max-width: 700px) {
.app { width: min(100% - 20px, 1440px); padding-top: 16px; }
.topbar { align-items: flex-start; }
.status { display: none; }
.controls { display: block; }
.control-group { border-bottom: 1px solid var(--line); padding: 15px 0; }
.metrics { grid-template-columns: repeat(2, 1fr); }
.chart-panel { min-height: 460px; padding: 12px; }
.canvas-wrap { min-height: 350px; }
.chart-toolbar { align-items: flex-start; flex-direction: column; }
}
</style>
</head>
<body>
<main class="app">
<header class="topbar">
<div class="brand">
<div class="logo" aria-hidden="true">
<svg viewBox="0 0 64 64" fill="none">
<path d="M19 31h26M32 18v28" stroke="#86ecff" stroke-width="4" stroke-linecap="round"/>
<rect x="23" y="23" width="18" height="18" rx="5" fill="#4f79ff" stroke="#b8f5ff" stroke-width="2"/>
<circle cx="13" cy="18" r="8" stroke="#6cf2b2" stroke-width="3"/>
<circle cx="51" cy="18" r="8" stroke="#6cf2b2" stroke-width="3"/>
<circle cx="13" cy="46" r="8" stroke="#6cf2b2" stroke-width="3"/>
<circle cx="51" cy="46" r="8" stroke="#6cf2b2" stroke-width="3"/>
</svg>
</div>
<div>
<h1> Alas de Da Vinci Simulador de Vuelo de Drone</h1>
<p class="subtitle">Modelo físico 2D con gravedad, arrastre y colisión con el suelo, Taller SEEC 2027 </p>
</div>
</div>
<div class="status"><span class="status-dot"></span> Motor de física listo</div>
</header>
<section class="dashboard">
<aside class="panel controls">
<div class="panel-title">
<h2>Parámetros de vuelo</h2>
<span class="tiny">Control</span>
</div>
<div class="control-group">
<div class="control-header">
<label for="thrust">Empuje</label>
<output class="value-box" id="thrustValue">15.0 N</output>
</div>
<input id="thrust" type="range" min="0" max="30" step="0.1" value="15" />
<div class="range-labels"><span>0 N</span><span>30 N</span></div>
</div>
<div class="control-group">
<div class="control-header">
<label for="angle">Ángulo</label>
<output class="value-box" id="angleValue">25°</output>
</div>
<input id="angle" type="range" min="-80" max="80" step="1" value="25" />
<div class="range-labels"><span>-80°</span><span>Vertical: 0°</span><span>80°</span></div>
</div>
<div class="control-group">
<div class="control-header">
<label for="duration">Tiempo total</label>
<output class="value-box" id="durationValue">8.0 s</output>
</div>
<input id="duration" type="range" min="1" max="20" step="0.5" value="8" />
<div class="range-labels"><span>1 s</span><span>20 s</span></div>
</div>
<div class="physics-card">
<div class="physics-row"><span>Masa del drone</span><strong>1.00 kg</strong></div>
<div class="physics-row"><span>Gravedad</span><strong>9.81 m/s²</strong></div>
<div class="physics-row"><span>Resistencia del aire</span><strong>0.20</strong></div>
<div class="physics-row"><span>Intervalo de simulación</span><strong>0.10 s</strong></div>
</div>
<div class="actions">
<button class="primary" id="simulateBtn">▶ Simular vuelo</button>
<button class="secondary" id="resetBtn" title="Restablecer parámetros" aria-label="Restablecer parámetros"></button>
</div>
</aside>
<div class="main">
<section class="metrics">
<article class="panel metric">
<div class="metric-label">Altura máxima</div>
<div class="metric-value"><span id="maxHeight">0.00</span><span class="metric-unit">m</span></div>
<div class="metric-note">Pico vertical</div>
</article>
<article class="panel metric">
<div class="metric-label">Distancia horizontal</div>
<div class="metric-value"><span id="distance">0.00</span><span class="metric-unit">m</span></div>
<div class="metric-note">Posición final X</div>
</article>
<article class="panel metric">
<div class="metric-label">Velocidad máxima</div>
<div class="metric-value"><span id="maxSpeed">0.00</span><span class="metric-unit">m/s</span></div>
<div class="metric-note">Magnitud de velocidad</div>
</article>
<article class="panel metric">
<div class="metric-label">Pasos calculados</div>
<div class="metric-value"><span id="steps">0</span></div>
<div class="metric-note">Resolución temporal</div>
</article>
</section>
<section class="panel chart-panel">
<div class="chart-toolbar">
<div>
<h2 style="margin:0 0 4px;font-size:1rem">Trayectoria del Drone</h2>
<div class="tiny">Posición horizontal contra altura</div>
</div>
<div class="legend"><span class="legend-line"></span> Trayectoria simulada</div>
</div>
<div class="canvas-wrap" id="canvasWrap">
<canvas id="chart" aria-label="Gráfica de la trayectoria del drone"></canvas>
<div class="chart-badge" id="flightState">Esperando simulación</div>
</div>
<p class="footer-note">El ángulo se mide respecto a la vertical. Un ángulo positivo impulsa el drone hacia la derecha.</p>
</section>
</div>
</section>
</main>
<script>
const MASS = 1.0;
const GRAVITY = 9.81;
const AIR_RESISTANCE = 0.2;
const DT = 0.1;
const el = id => document.getElementById(id);
const thrust = el('thrust');
const angle = el('angle');
const duration = el('duration');
const canvas = el('chart');
const ctx = canvas.getContext('2d');
let latest = null;
let animationFrame = null;
function setRangeFill(input) {
const percent = ((input.value - input.min) / (input.max - input.min)) * 100;
input.style.setProperty('--fill', `${percent}%`);
}
function updateControls() {
el('thrustValue').textContent = `${Number(thrust.value).toFixed(1)} N`;
el('angleValue').textContent = `${Number(angle.value)}°`;
el('durationValue').textContent = `${Number(duration.value).toFixed(1)} s`;
[thrust, angle, duration].forEach(setRangeFill);
}
function simulate() {
const thrustForce = Number(thrust.value);
const angleRad = Number(angle.value) * Math.PI / 180;
const totalTime = Number(duration.value);
const stepCount = Math.floor(totalTime / DT);
let x = 0, y = 0, vx = 0, vy = 0;
const points = [{ x, y, vx, vy, speed: 0, t: 0 }];
let maxHeight = 0;
let maxSpeed = 0;
for (let i = 0; i < stepCount; i++) {
let ax = (thrustForce * Math.sin(angleRad)) / MASS;
let ay = (thrustForce * Math.cos(angleRad)) / MASS - GRAVITY;
ax -= AIR_RESISTANCE * vx;
ay -= AIR_RESISTANCE * vy;
vx += ax * DT;
vy += ay * DT;
x += vx * DT;
y += vy * DT;
if (y < 0) {
y = 0;
vy = 0;
vx *= 0.5;
}
const speed = Math.hypot(vx, vy);
maxHeight = Math.max(maxHeight, y);
maxSpeed = Math.max(maxSpeed, speed);
points.push({ x, y, vx, vy, speed, t: (i + 1) * DT });
}
latest = { points, maxHeight, maxSpeed, distance: x, steps: stepCount };
updateMetrics(latest);
animateChart(latest);
}
function updateMetrics(data) {
el('maxHeight').textContent = data.maxHeight.toFixed(2);
el('distance').textContent = data.distance.toFixed(2);
el('maxSpeed').textContent = data.maxSpeed.toFixed(2);
el('steps').textContent = data.steps;
}
function resizeCanvas() {
const rect = canvas.parentElement.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (latest) drawChart(latest, latest.points.length - 1);
else drawEmpty();
}
function niceMax(value) {
if (value <= 0) return 5;
const power = Math.pow(10, Math.floor(Math.log10(value)));
const normalized = value / power;
const nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
return nice * power;
}
function chartGeometry(data) {
const width = canvas.clientWidth;
const height = canvas.clientHeight;
const pad = { left: 58, right: 24, top: 24, bottom: 48 };
const xs = data.points.map(p => p.x);
const minXRaw = Math.min(0, ...xs);
const maxXRaw = Math.max(0, ...xs);
let spanX = maxXRaw - minXRaw;
if (spanX < 1) spanX = 1;
const xMargin = spanX * 0.08;
const minX = minXRaw - xMargin;
const maxX = maxXRaw + xMargin;
const maxY = niceMax(Math.max(1, data.maxHeight * 1.15));
const plotW = width - pad.left - pad.right;
const plotH = height - pad.top - pad.bottom;
return {
width, height, pad, minX, maxX, maxY, plotW, plotH,
px: x => pad.left + ((x - minX) / (maxX - minX)) * plotW,
py: y => pad.top + plotH - (y / maxY) * plotH
};
}
function drawGrid(g) {
ctx.clearRect(0, 0, g.width, g.height);
ctx.font = '11px Inter, system-ui, sans-serif';
ctx.lineWidth = 1;
const divisions = 5;
for (let i = 0; i <= divisions; i++) {
const yy = g.pad.top + (i / divisions) * g.plotH;
const yVal = g.maxY * (1 - i / divisions);
ctx.strokeStyle = 'rgba(148, 163, 184, 0.11)';
ctx.beginPath(); ctx.moveTo(g.pad.left, yy); ctx.lineTo(g.width - g.pad.right, yy); ctx.stroke();
ctx.fillStyle = '#71879d';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(yVal.toFixed(yVal < 10 ? 1 : 0), g.pad.left - 10, yy);
}
for (let i = 0; i <= divisions; i++) {
const xx = g.pad.left + (i / divisions) * g.plotW;
const xVal = g.minX + (i / divisions) * (g.maxX - g.minX);
ctx.strokeStyle = 'rgba(148, 163, 184, 0.085)';
ctx.beginPath(); ctx.moveTo(xx, g.pad.top); ctx.lineTo(xx, g.height - g.pad.bottom); ctx.stroke();
ctx.fillStyle = '#71879d';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(xVal.toFixed(Math.abs(xVal) < 10 ? 1 : 0), xx, g.height - g.pad.bottom + 10);
}
ctx.strokeStyle = 'rgba(108, 242, 178, 0.38)';
ctx.lineWidth = 2;
const groundY = g.py(0);
ctx.beginPath(); ctx.moveTo(g.pad.left, groundY); ctx.lineTo(g.width - g.pad.right, groundY); ctx.stroke();
ctx.fillStyle = '#8197ac';
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText('Distancia horizontal (m)', g.pad.left + g.plotW / 2, g.height - 5);
ctx.save();
ctx.translate(15, g.pad.top + g.plotH / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('Altura (m)', 0, 0);
ctx.restore();
}
function drawDrone(x, y, rotation = 0) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(rotation);
ctx.shadowColor = 'rgba(67,216,255,.7)';
ctx.shadowBlur = 15;
ctx.strokeStyle = '#b7f5ff';
ctx.fillStyle = '#4f79ff';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.roundRect(-9, -6, 18, 12, 4); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-14, 0); ctx.lineTo(14, 0); ctx.moveTo(0, -10); ctx.lineTo(0, 10); ctx.stroke();
ctx.fillStyle = '#6cf2b2';
[[-14,0],[14,0],[0,-10],[0,10]].forEach(([cx,cy]) => {
ctx.beginPath(); ctx.arc(cx, cy, 3.2, 0, Math.PI * 2); ctx.fill();
});
ctx.restore();
}
function drawChart(data, endIndex) {
const g = chartGeometry(data);
drawGrid(g);
const pts = data.points.slice(0, endIndex + 1);
if (pts.length < 2) return;
const gradient = ctx.createLinearGradient(g.pad.left, 0, g.width - g.pad.right, 0);
gradient.addColorStop(0, '#43d8ff');
gradient.addColorStop(1, '#6cf2b2');
ctx.save();
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = gradient;
ctx.shadowColor = 'rgba(67,216,255,.42)';
ctx.shadowBlur = 10;
ctx.beginPath();
pts.forEach((p, i) => {
const xx = g.px(p.x), yy = g.py(p.y);
if (i === 0) ctx.moveTo(xx, yy); else ctx.lineTo(xx, yy);
});
ctx.stroke();
ctx.restore();
const current = pts[pts.length - 1];
const previous = pts[Math.max(0, pts.length - 2)];
const rotation = Math.atan2(-(current.y - previous.y), current.x - previous.x || 0.0001) * 0.22;
drawDrone(g.px(current.x), g.py(current.y), rotation);
}
function drawEmpty() {
const mock = { points: [{x:0,y:0}], maxHeight: 5 };
const g = chartGeometry(mock);
drawGrid(g);
ctx.fillStyle = '#60778e';
ctx.font = '500 13px Inter, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Configura los parámetros y ejecuta la simulación', g.pad.left + g.plotW / 2, g.pad.top + g.plotH / 2);
}
function animateChart(data) {
cancelAnimationFrame(animationFrame);
const totalFrames = Math.min(150, Math.max(45, data.points.length));
let frame = 0;
el('flightState').textContent = 'Simulando vuelo…';
function tick() {
frame++;
const progress = Math.min(1, frame / totalFrames);
const eased = 1 - Math.pow(1 - progress, 3);
const endIndex = Math.floor(eased * (data.points.length - 1));
drawChart(data, endIndex);
if (progress < 1) animationFrame = requestAnimationFrame(tick);
else el('flightState').textContent = `Vuelo completado · ${data.steps} pasos`;
}
animationFrame = requestAnimationFrame(tick);
}
function reset() {
thrust.value = 15;
angle.value = 25;
duration.value = 8;
updateControls();
simulate();
}
[thrust, angle, duration].forEach(input => {
input.addEventListener('input', updateControls);
input.addEventListener('change', simulate);
});
el('simulateBtn').addEventListener('click', simulate);
el('resetBtn').addEventListener('click', reset);
window.addEventListener('resize', resizeCanvas);
updateControls();
requestAnimationFrame(() => {
resizeCanvas();
simulate();
});
</script>
</body>
</html>