Spaces:
Running
Running
| <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> |