Create a web-based application that simulates real-time airflow over a NACA airfoil, with interactive controls for modifying airfoil shape (NACA 4-digit), angle of attack, and wind speed. The airflow visualization must be responsive in real time within a browser using WebGL. ✅ Functional Requirements: Dynamic NACA Airfoil Generation: User inputs a NACA 4-digit code (e.g., 2412). Airfoil geometry is generated dynamically using standard equations. Real-Time Airflow Visualization: Simulate 2D or 2.5D airflow around the airfoil using: Lattice Boltzmann Method (LBM) or potential flow (fast, browser-safe). Fragment shaders (GPU) for fluid advection (WebGL via GLSL). Show streamlines or particle advection using vector fields. User Interaction: Inputs: NACA code (text input) Angle of attack (slider) Wind speed (slider) Button: “Reset Flow” / “Pause Simulation” Visualization: Display pressure regions with color map (red/blue for high/low). Show lift and drag indicators. Optionally: graphs of lift coefficient vs angle of attack. 🔧 Technologies: Frontend: HTML + CSS + JS WebGL via Three.js or custom GLSL shaders UI: dat.GUI, React, or simple DOM elements Physics/Simulation: JSPF (JavaScript Potential Flow) Or use Navier-Stokes approximation with shaders Option: WebAssembly + Rust/Fortran for fast CFD kernel 🎯 Goal: Enable users to interactively explore how airflow behaves over different NACA airfoils in real time, all within the browser (no external app or backend). - Initial Deployment
8ca6130 verified | <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>NACA Airfoil Flow Simulator</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/dat.gui@0.7.7/build/dat.gui.min.js"></script> | |
| <style> | |
| .pressure-gradient { | |
| background: linear-gradient(90deg, #0000ff, #ffffff, #ff0000); | |
| } | |
| canvas { | |
| display: block; | |
| width: 100% ; | |
| height: auto ; | |
| } | |
| .tooltip { | |
| position: relative; | |
| display: inline-block; | |
| } | |
| .tooltip .tooltiptext { | |
| visibility: hidden; | |
| width: 200px; | |
| background-color: #333; | |
| color: #fff; | |
| text-align: center; | |
| border-radius: 6px; | |
| padding: 5px; | |
| position: absolute; | |
| z-index: 1; | |
| bottom: 125%; | |
| left: 50%; | |
| margin-left: -100px; | |
| opacity: 0; | |
| transition: opacity 0.3s; | |
| } | |
| .tooltip:hover .tooltiptext { | |
| visibility: visible; | |
| opacity: 1; | |
| } | |
| #renderCanvas { | |
| touch-action: none; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-100 font-sans"> | |
| <div class="container mx-auto px-4 py-8"> | |
| <header class="mb-8"> | |
| <h1 class="text-4xl font-bold text-center text-blue-800 mb-2">NACA Airfoil Flow Simulator</h1> | |
| <p class="text-center text-gray-600">Interactive real-time airflow visualization over NACA airfoils</p> | |
| </header> | |
| <div class="flex flex-col lg:flex-row gap-8"> | |
| <!-- Controls Panel --> | |
| <div class="w-full lg:w-1/4 bg-white rounded-lg shadow-lg p-6"> | |
| <h2 class="text-2xl font-semibold mb-4 text-gray-800">Controls</h2> | |
| <div class="mb-6"> | |
| <label for="nacaCode" class="block text-sm font-medium text-gray-700 mb-1">NACA 4-digit Code</label> | |
| <div class="flex"> | |
| <input type="text" id="nacaCode" value="2412" maxlength="4" | |
| class="flex-1 px-3 py-2 border border-gray-300 rounded-l-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| <button id="updateAirfoil" class="bg-blue-600 text-white px-4 py-2 rounded-r-md hover:bg-blue-700 transition"> | |
| Update | |
| </button> | |
| </div> | |
| <p class="text-xs text-gray-500 mt-1">Enter a 4-digit NACA code (e.g., 2412, 0012)</p> | |
| </div> | |
| <div class="mb-6"> | |
| <label for="aoaSlider" class="block text-sm font-medium text-gray-700 mb-1"> | |
| Angle of Attack: <span id="aoaValue">5</span>° | |
| </label> | |
| <input type="range" id="aoaSlider" min="-15" max="15" value="5" step="0.5" | |
| class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"> | |
| </div> | |
| <div class="mb-6"> | |
| <label for="windSpeedSlider" class="block text-sm font-medium text-gray-700 mb-1"> | |
| Wind Speed: <span id="windSpeedValue">10</span> m/s | |
| </label> | |
| <input type="range" id="windSpeedSlider" min="1" max="30" value="10" step="1" | |
| class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"> | |
| </div> | |
| <div class="mb-6"> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Flow Visualization</label> | |
| <select id="visualizationMode" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| <option value="streamlines">Streamlines</option> | |
| <option value="particles">Particles</option> | |
| <option value="pressure">Pressure Field</option> | |
| </select> | |
| </div> | |
| <div class="flex space-x-3 mb-6"> | |
| <button id="resetFlow" class="flex-1 bg-gray-600 text-white px-4 py-2 rounded-md hover:bg-gray-700 transition"> | |
| Reset Flow | |
| </button> | |
| <button id="pauseSim" class="flex-1 bg-yellow-600 text-white px-4 py-2 rounded-md hover:bg-yellow-700 transition"> | |
| Pause | |
| </button> | |
| </div> | |
| <div class="bg-gray-100 p-4 rounded-lg"> | |
| <h3 class="font-medium text-gray-800 mb-2">Performance Metrics</h3> | |
| <div class="grid grid-cols-2 gap-2"> | |
| <div> | |
| <p class="text-xs text-gray-600">Lift Coefficient (C<sub>L</sub>):</p> | |
| <p id="liftCoeff" class="font-bold">0.75</p> | |
| </div> | |
| <div> | |
| <p class="text-xs text-gray-600">Drag Coefficient (C<sub>D</sub>):</p> | |
| <p id="dragCoeff" class="font-bold">0.02</p> | |
| </div> | |
| <div> | |
| <p class="text-xs text-gray-600">Reynolds Number:</p> | |
| <p id="reynoldsNumber" class="font-bold">6.7×10<sup>5</sup></p> | |
| </div> | |
| <div> | |
| <p class="text-xs text-gray-600">FPS:</p> | |
| <p id="fpsCounter" class="font-bold">60</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="mt-4"> | |
| <div class="flex items-center justify-between mb-1"> | |
| <span class="text-sm text-gray-700">Pressure Gradient</span> | |
| </div> | |
| <div class="pressure-gradient h-4 rounded-md"></div> | |
| <div class="flex justify-between text-xs text-gray-600 mt-1"> | |
| <span>Low</span> | |
| <span>High</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Visualization Canvas --> | |
| <div class="w-full lg:w-3/4"> | |
| <div class="bg-white rounded-lg shadow-lg overflow-hidden"> | |
| <div id="renderCanvas" class="w-full h-96 lg:h-[32rem]"></div> | |
| </div> | |
| <div class="mt-4 bg-white rounded-lg shadow-lg p-4"> | |
| <h3 class="font-medium text-gray-800 mb-2">About NACA Airfoils</h3> | |
| <p class="text-sm text-gray-600"> | |
| The NACA 4-digit series defines airfoil geometry using 4 digits (e.g., 2412): | |
| <span class="tooltip"> | |
| <span class="font-bold">[?]</span> | |
| <span class="tooltiptext"> | |
| First digit: maximum camber (% of chord)<br> | |
| Second digit: camber position (tenths of chord)<br> | |
| Last two digits: maximum thickness (% of chord) | |
| </span> | |
| </span> | |
| </p> | |
| <div class="mt-2 flex justify-center"> | |
| <button id="showClGraph" class="bg-blue-100 text-blue-800 px-4 py-2 rounded-md hover:bg-blue-200 transition text-sm"> | |
| Show C<sub>L</sub> vs Angle of Attack | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Graph Modal --> | |
| <div id="graphModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center hidden z-50"> | |
| <div class="bg-white rounded-lg p-6 w-11/12 max-w-3xl"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h3 class="text-xl font-semibold">Lift Coefficient vs Angle of Attack</h3> | |
| <button id="closeModal" class="text-gray-500 hover:text-gray-700"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> | |
| </svg> | |
| </button> | |
| </div> | |
| <div class="border border-gray-200 rounded-lg p-4"> | |
| <canvas id="clGraphCanvas" class="w-full h-64"></canvas> | |
| </div> | |
| <div class="mt-4 text-sm text-gray-600"> | |
| <p>This graph shows the theoretical lift coefficient variation with angle of attack for the current NACA airfoil.</p> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // Main simulation variables | |
| let scene, camera, renderer, airfoilMesh, flowParticles, flowField; | |
| let simulationPaused = false; | |
| let lastTimestamp = 0; | |
| let frameCount = 0; | |
| let lastFpsUpdate = 0; | |
| let currentNacaCode = "2412"; | |
| let currentAoa = 5; | |
| let currentWindSpeed = 10; | |
| let currentVisualization = "streamlines"; | |
| // Initialize Three.js scene | |
| function initScene() { | |
| const canvas = document.getElementById('renderCanvas'); | |
| // Scene setup | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0xf0f0f0); | |
| // Camera setup | |
| camera = new THREE.PerspectiveCamera(45, canvas.clientWidth / canvas.clientHeight, 0.1, 1000); | |
| camera.position.set(0, 0, 20); | |
| camera.lookAt(0, 0, 0); | |
| // Renderer setup | |
| renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(canvas.clientWidth, canvas.clientHeight); | |
| canvas.appendChild(renderer.domElement); | |
| // Lighting | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); | |
| scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
| directionalLight.position.set(1, 1, 1); | |
| scene.add(directionalLight); | |
| // Grid helper | |
| const gridHelper = new THREE.GridHelper(20, 20, 0x888888, 0xcccccc); | |
| scene.add(gridHelper); | |
| // Create initial airfoil | |
| createAirfoil(currentNacaCode); | |
| // Create flow visualization | |
| createFlowVisualization(); | |
| // Handle window resize | |
| window.addEventListener('resize', onWindowResize); | |
| // Start animation loop | |
| animate(); | |
| } | |
| // Create NACA airfoil geometry | |
| function createAirfoil(nacaCode) { | |
| // Remove existing airfoil if present | |
| if (airfoilMesh) { | |
| scene.remove(airfoilMesh); | |
| } | |
| // Parse NACA code | |
| const m = parseInt(nacaCode[0]) / 100; // Maximum camber | |
| const p = parseInt(nacaCode[1]) / 10; // Position of maximum camber | |
| const t = parseInt(nacaCode.substring(2)) / 100; // Thickness | |
| // Generate airfoil points | |
| const points = []; | |
| const chordLength = 10; | |
| const resolution = 100; | |
| for (let i = 0; i <= resolution; i++) { | |
| const x = i / resolution; | |
| // Thickness distribution | |
| const yt = 5 * t * (0.2969 * Math.sqrt(x) - 0.1260 * x - 0.3516 * x*x + 0.2843 * x*x*x - 0.1015 * x*x*x*x); | |
| // Camber line | |
| let yc, dyc; | |
| if (x < p) { | |
| yc = m * (2 * p * x - x * x) / (p * p); | |
| dyc = 2 * m * (p - x) / (p * p); | |
| } else { | |
| yc = m * ((1 - 2 * p) + 2 * p * x - x * x) / ((1 - p) * (1 - p)); | |
| dyc = 2 * m * (p - x) / ((1 - p) * (1 - p)); | |
| } | |
| // Upper and lower surfaces | |
| const theta = Math.atan(dyc); | |
| const xu = x - yt * Math.sin(theta); | |
| const yu = yc + yt * Math.cos(theta); | |
| const xl = x + yt * Math.sin(theta); | |
| const yl = yc - yt * Math.cos(theta); | |
| points.push(new THREE.Vector3((xu - 0.5) * chordLength, yu * chordLength, 0)); | |
| if (i > 0 && i < resolution) { | |
| points.push(new THREE.Vector3((xl - 0.5) * chordLength, yl * chordLength, 0)); | |
| } | |
| } | |
| // Create geometry | |
| const shape = new THREE.Shape(); | |
| shape.moveTo(points[0].x, points[0].y); | |
| for (let i = 1; i < points.length; i++) { | |
| shape.lineTo(points[i].x, points[i].y); | |
| } | |
| shape.lineTo(points[0].x, points[0].y); | |
| const extrudeSettings = { | |
| steps: 1, | |
| depth: 0.5, | |
| bevelEnabled: false | |
| }; | |
| const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings); | |
| // Create mesh | |
| const material = new THREE.MeshPhongMaterial({ | |
| color: 0x3b82f6, | |
| specular: 0x111111, | |
| shininess: 30, | |
| side: THREE.DoubleSide | |
| }); | |
| airfoilMesh = new THREE.Mesh(geometry, material); | |
| airfoilMesh.rotation.z = -currentAoa * Math.PI / 180; | |
| scene.add(airfoilMesh); | |
| // Update metrics | |
| updateMetrics(); | |
| } | |
| // Create flow visualization | |
| function createFlowVisualization() { | |
| // Remove existing flow if present | |
| if (flowParticles) { | |
| scene.remove(flowParticles); | |
| } | |
| // Create particle system for flow visualization | |
| const particleCount = currentVisualization === "particles" ? 500 : 100; | |
| const particles = new THREE.BufferGeometry(); | |
| const positions = new Float32Array(particleCount * 3); | |
| const colors = new Float32Array(particleCount * 3); | |
| const sizes = new Float32Array(particleCount); | |
| // Initialize particles | |
| for (let i = 0; i < particleCount; i++) { | |
| // Random positions in front of the airfoil | |
| positions[i * 3] = (Math.random() - 0.5) * 20; | |
| positions[i * 3 + 1] = (Math.random() - 0.5) * 10 - 5; | |
| positions[i * 3 + 2] = 0; | |
| // Colors based on velocity (blue = slow, red = fast) | |
| colors[i * 3] = 0.2 + Math.random() * 0.8; | |
| colors[i * 3 + 1] = 0.2; | |
| colors[i * 3 + 2] = 0.8 - Math.random() * 0.6; | |
| sizes[i] = currentVisualization === "particles" ? 0.1 : 0.05; | |
| } | |
| particles.setAttribute('position', new THREE.BufferAttribute(positions, 3)); | |
| particles.setAttribute('color', new THREE.BufferAttribute(colors, 3)); | |
| particles.setAttribute('size', new THREE.BufferAttribute(sizes, 1)); | |
| // Particle material | |
| const particleMaterial = new THREE.PointsMaterial({ | |
| size: 0.1, | |
| vertexColors: true, | |
| transparent: true, | |
| opacity: 0.8, | |
| sizeAttenuation: true | |
| }); | |
| flowParticles = new THREE.Points(particles, particleMaterial); | |
| scene.add(flowParticles); | |
| // Create flow field for simulation | |
| flowField = { | |
| particles: positions, | |
| velocities: new Float32Array(particleCount * 3), | |
| ages: new Float32Array(particleCount) | |
| }; | |
| // Initialize velocities and ages | |
| for (let i = 0; i < particleCount; i++) { | |
| flowField.velocities[i * 3] = currentWindSpeed * 0.05; | |
| flowField.velocities[i * 3 + 1] = 0; | |
| flowField.velocities[i * 3 + 2] = 0; | |
| flowField.ages[i] = Math.random() * 100; | |
| } | |
| } | |
| // Update flow simulation | |
| function updateFlow(deltaTime) { | |
| if (simulationPaused) return; | |
| const positions = flowParticles.geometry.attributes.position.array; | |
| const colors = flowParticles.geometry.attributes.color.array; | |
| const particleCount = positions.length / 3; | |
| for (let i = 0; i < particleCount; i++) { | |
| const idx = i * 3; | |
| // Update age | |
| flowField.ages[i] += deltaTime; | |
| // Reset particles that are too old or out of bounds | |
| if (flowField.ages[i] > 100 || | |
| positions[idx] < -12 || positions[idx] > 12 || | |
| positions[idx + 1] < -8 || positions[idx + 1] > 8) { | |
| positions[idx] = (Math.random() - 0.5) * 20 - 8; | |
| positions[idx + 1] = (Math.random() - 0.5) * 10; | |
| positions[idx + 2] = 0; | |
| flowField.velocities[idx] = currentWindSpeed * 0.05; | |
| flowField.velocities[idx + 1] = 0; | |
| flowField.velocities[idx + 2] = 0; | |
| flowField.ages[i] = 0; | |
| // Set initial color | |
| colors[idx] = 0.2 + Math.random() * 0.8; | |
| colors[idx + 1] = 0.2; | |
| colors[idx + 2] = 0.8 - Math.random() * 0.6; | |
| } | |
| // Simple flow simulation (potential flow approximation) | |
| const x = positions[idx]; | |
| const y = positions[idx + 1]; | |
| // Distance to airfoil center | |
| const dx = x - airfoilMesh.position.x; | |
| const dy = y - airfoilMesh.position.y; | |
| const distSq = dx * dx + dy * dy; | |
| // Basic flow around a cylinder approximation | |
| if (distSq < 16) { | |
| // Near the airfoil, add some disturbance | |
| const angle = Math.atan2(dy, dx); | |
| const radius = Math.sqrt(distSq); | |
| // Tangential velocity increases closer to the airfoil | |
| const tangentialFactor = (1 / (radius * radius)) * 2; | |
| // Add angle of attack effect | |
| const aoaEffect = Math.sin(angle - airfoilMesh.rotation.z) * currentAoa * 0.01; | |
| // Update velocity | |
| flowField.velocities[idx] = currentWindSpeed * 0.05 * (Math.cos(angle) - tangentialFactor * Math.sin(angle) + aoaEffect); | |
| flowField.velocities[idx + 1] = currentWindSpeed * 0.05 * (Math.sin(angle) + tangentialFactor * Math.cos(angle) + aoaEffect); | |
| // Update color based on velocity magnitude | |
| const velMag = Math.sqrt( | |
| flowField.velocities[idx] * flowField.velocities[idx] + | |
| flowField.velocities[idx + 1] * flowField.velocities[idx + 1] | |
| ); | |
| // Map velocity to color (blue = low, red = high) | |
| colors[idx] = 0.2 + Math.min(velMag * 10, 0.8); | |
| colors[idx + 1] = 0.2; | |
| colors[idx + 2] = 0.8 - Math.min(velMag * 5, 0.6); | |
| } else { | |
| // Far from airfoil, maintain free stream velocity | |
| flowField.velocities[idx] = currentWindSpeed * 0.05; | |
| flowField.velocities[idx + 1] = 0; | |
| } | |
| // Update position | |
| positions[idx] += flowField.velocities[idx] * deltaTime * 60; | |
| positions[idx + 1] += flowField.velocities[idx + 1] * deltaTime * 60; | |
| } | |
| // Mark attributes as needing update | |
| flowParticles.geometry.attributes.position.needsUpdate = true; | |
| flowParticles.geometry.attributes.color.needsUpdate = true; | |
| } | |
| // Update performance metrics | |
| function updateMetrics() { | |
| // Simple lift and drag coefficient calculations based on thin airfoil theory | |
| const aoaRad = currentAoa * Math.PI / 180; | |
| const cl = 2 * Math.PI * aoaRad + 0.1 * currentAoa; // Rough approximation | |
| const cd = 0.01 + 0.1 * aoaRad * aoaRad; // Parasite drag + induced drag | |
| document.getElementById('liftCoeff').textContent = cl.toFixed(3); | |
| document.getElementById('dragCoeff').textContent = cd.toFixed(3); | |
| // Reynolds number calculation (Re = ρVL/μ) | |
| const chordLength = 1; // meters (reference length) | |
| const airDensity = 1.225; // kg/m³ | |
| const dynamicViscosity = 1.8e-5; // Pa·s | |
| const re = (airDensity * currentWindSpeed * chordLength) / dynamicViscosity; | |
| document.getElementById('reynoldsNumber').textContent = (re / 1e5).toFixed(1) + "×10<sup>5</sup>"; | |
| } | |
| // Animation loop | |
| function animate(timestamp = 0) { | |
| requestAnimationFrame(animate); | |
| // Calculate delta time for smooth animation | |
| const deltaTime = (timestamp - lastTimestamp) / 1000; | |
| lastTimestamp = timestamp; | |
| // Update FPS counter | |
| frameCount++; | |
| if (timestamp - lastFpsUpdate >= 1000) { | |
| document.getElementById('fpsCounter').textContent = frameCount; | |
| frameCount = 0; | |
| lastFpsUpdate = timestamp; | |
| } | |
| // Update flow simulation | |
| updateFlow(deltaTime); | |
| // Rotate airfoil if angle of attack changed | |
| airfoilMesh.rotation.z = -currentAoa * Math.PI / 180; | |
| // Render scene | |
| renderer.render(scene, camera); | |
| } | |
| // Handle window resize | |
| function onWindowResize() { | |
| const canvas = document.getElementById('renderCanvas'); | |
| camera.aspect = canvas.clientWidth / canvas.clientHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(canvas.clientWidth, canvas.clientHeight); | |
| } | |
| // Initialize UI event listeners | |
| function initUI() { | |
| // NACA code input | |
| document.getElementById('updateAirfoil').addEventListener('click', () => { | |
| const newCode = document.getElementById('nacaCode').value; | |
| if (/^\d{4}$/.test(newCode)) { | |
| currentNacaCode = newCode; | |
| createAirfoil(currentNacaCode); | |
| } else { | |
| alert("Please enter a valid 4-digit NACA code (e.g., 2412)"); | |
| } | |
| }); | |
| // Angle of attack slider | |
| document.getElementById('aoaSlider').addEventListener('input', (e) => { | |
| currentAoa = parseFloat(e.target.value); | |
| document.getElementById('aoaValue').textContent = currentAoa; | |
| updateMetrics(); | |
| }); | |
| // Wind speed slider | |
| document.getElementById('windSpeedSlider').addEventListener('input', (e) => { | |
| currentWindSpeed = parseFloat(e.target.value); | |
| document.getElementById('windSpeedValue').textContent = currentWindSpeed; | |
| updateMetrics(); | |
| }); | |
| // Visualization mode | |
| document.getElementById('visualizationMode').addEventListener('change', (e) => { | |
| currentVisualization = e.target.value; | |
| createFlowVisualization(); | |
| }); | |
| // Reset flow button | |
| document.getElementById('resetFlow').addEventListener('click', () => { | |
| createFlowVisualization(); | |
| }); | |
| // Pause simulation button | |
| document.getElementById('pauseSim').addEventListener('click', () => { | |
| simulationPaused = !simulationPaused; | |
| document.getElementById('pauseSim').textContent = simulationPaused ? "Resume" : "Pause"; | |
| document.getElementById('pauseSim').className = simulationPaused ? | |
| "flex-1 bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700 transition" : | |
| "flex-1 bg-yellow-600 text-white px-4 py-2 rounded-md hover:bg-yellow-700 transition"; | |
| }); | |
| // Show CL graph button | |
| document.getElementById('showClGraph').addEventListener('click', showClGraph); | |
| document.getElementById('closeModal').addEventListener('click', () => { | |
| document.getElementById('graphModal').classList.add('hidden'); | |
| }); | |
| } | |
| // Show CL vs AoA graph | |
| function showClGraph() { | |
| const modal = document.getElementById('graphModal'); | |
| modal.classList.remove('hidden'); | |
| // Create graph if not already exists | |
| if (!window.clGraph) { | |
| const canvas = document.getElementById('clGraphCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| // Generate data points | |
| const data = []; | |
| for (let aoa = -15; aoa <= 15; aoa += 0.5) { | |
| const aoaRad = aoa * Math.PI / 180; | |
| const cl = 2 * Math.PI * aoaRad + 0.1 * aoa; // Rough approximation | |
| data.push({x: aoa, y: cl}); | |
| } | |
| // Draw graph | |
| window.clGraph = new Chart(ctx, { | |
| type: 'line', | |
| data: { | |
| datasets: [{ | |
| label: 'Lift Coefficient (CL)', | |
| data: data, | |
| borderColor: 'rgb(59, 130, 246)', | |
| backgroundColor: 'rgba(59, 130, 246, 0.1)', | |
| borderWidth: 2, | |
| pointRadius: 0, | |
| fill: true | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| scales: { | |
| x: { | |
| type: 'linear', | |
| position: 'center', | |
| title: { | |
| display: true, | |
| text: 'Angle of Attack (°)' | |
| }, | |
| min: -15, | |
| max: 15, | |
| ticks: { | |
| stepSize: 5 | |
| } | |
| }, | |
| y: { | |
| title: { | |
| display: true, | |
| text: 'Lift Coefficient (CL)' | |
| }, | |
| min: -1.5, | |
| max: 1.5 | |
| } | |
| }, | |
| plugins: { | |
| legend: { | |
| position: 'top', | |
| }, | |
| tooltip: { | |
| callbacks: { | |
| label: function(context) { | |
| return `CL: ${context.parsed.y.toFixed(3)} at ${context.parsed.x}°`; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| } | |
| // Initialize everything when DOM is loaded | |
| document.addEventListener('DOMContentLoaded', () => { | |
| initScene(); | |
| initUI(); | |
| }); | |
| </script> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> | |
| <p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=Aerotech/naca" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> | |
| </html> |