<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Interactive 3D DNA Simulator</title>
<script src="https://cdn.tailwindcss.com"></script>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.128.0/examples/jsm/"
}
}
</script>
<style>
body { background: radial-gradient(circle at center, #0f172a, #000); font-family: 'Inter', sans-serif; }
#simulation-container canvas { cursor: pointer; }
.base-button.selected { outline: 2px solid #6366F1; transform: scale(1.05); }
</style>
</head>
<body class="text-gray-200 flex flex-col items-center justify-center min-h-screen p-4">
<div class="flex flex-col items-center">
<div id="simulation-container" class="w-[1000px] h-[1000px] bg-black rounded-t-2xl shadow-2xl overflow-hidden relative border-2 border-gray-700"></div>
<div id="ui-container" class="w-[1000px] h-[300px] rounded-b-2xl shadow-2xl p-6 flex flex-col justify-between bg-gray-800/80">
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-extrabold text-white">Interactive DNA Strand Simulator</h1>
<p class="text-gray-400">Select a preset or click on a base pair to edit the sequence.</p>
</div>
<div>
<label for="sample-select" class="text-sm text-gray-400 block mb-1">Load DNA Sample</label>
<select id="sample-select" class="bg-gray-700 border border-gray-600 rounded-md p-2 w-72"></select>
</div>
</div>
<div id="editor-panel" class="flex-grow flex items-center justify-center gap-8 bg-gray-900/50 rounded-lg p-4 border border-gray-700">
<div class="text-center">
<p class="text-gray-400 text-sm">Selected Pair Index</p>
<p id="selected-index" class="text-3xl fira-code">-</p>
</div>
<div class="w-px h-16 bg-gray-600"></div>
<div class="text-center">
<p class="text-gray-400 text-sm">Current Strand</p>
<p id="selected-base" class="text-5xl fira-code font-bold">-</p>
</div>
<div class="text-center">
<p class="text-gray-400 text-sm">Complementary Strand</p>
<p id="complementary-base" class="text-5xl fira-code font-bold">-</p>
</div>
<div class="w-px h-16 bg-gray-600"></div>
<div id="base-editor" class="text-center space-y-2 opacity-25 transition-opacity">
<p class="text-gray-400 text-sm">Change Base To</p>
<div id="base-buttons" class="flex gap-2"></div>
</div>
</div>
</div>
</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
let scene, camera, renderer, controls, dnaGroup;
let dnaSequence = '';
let interactiveObjects = [];
let selectedPair = null;
const BASE_PAIRS = { 'A': 'T', 'T': 'A', 'C': 'G', 'G': 'C' };
const BASE_COLORS = { 'A': 0x0077ff, 'T': 0x00ff00, 'C': 0xffff00, 'G': 0xff0000 };
const dnaSamples = {
'sickle-cell': { name: 'Sickle Cell Anemia (HBB Gene)', sequence: 'CCTGTGGAG' },
'normal-hemoglobin': { name: 'Normal Hemoglobin (HBB Gene)', sequence: 'CCTGAGGAG' },
'cystic-fibrosis': { name: 'Cystic Fibrosis (CFTR ΔF508)', sequence: 'ATCATCGGTGTT' }
};
const simContainer = document.getElementById('simulation-container');
const sampleSelect = document.getElementById('sample-select');
const buildButton = document.getElementById('build-button');
const selectedIndex = document.getElementById('selected-index');
const selectedBase = document.getElementById('selected-base');
const complementaryBase = document.getElementById('complementary-base');
const baseEditor = document.getElementById('base-editor');
const baseButtons = document.getElementById('base-buttons');
function initialize() {
init3D();
setupUI();
loadSample('sickle-cell');
buildDNA();
animate3D();
}
function setupUI() {
for (const key in dnaSamples) {
const option = document.createElement('option');
option.value = key;
option.textContent = dnaSamples[key].name;
sampleSelect.appendChild(option);
}
sampleSelect.value = 'sickle-cell';
sampleSelect.addEventListener('change', () => loadSample(sampleSelect.value));
['A', 'T', 'C', 'G'].forEach(base => {
const button = document.createElement('button');
button.textContent = base;
button.dataset.base = base;
button.className = `base-button w-10 h-10 rounded-full font-bold`;
button.style.backgroundColor = `#${BASE_COLORS[base].toString(16).padStart(6, '0')}`;
button.style.color = (base === 'C' || base === 'G') ? '#333' : '#fff';
button.addEventListener('click', () => handleBaseChange(base));
baseButtons.appendChild(button);
});
simContainer.addEventListener('click', onCanvasClick);
}
function loadSample(sampleKey) {
if (dnaSamples[sampleKey]) {
dnaSequence = dnaSamples[sampleKey].sequence;
buildDNA();
}
}
function updateEditorPanel() {
if (selectedPair) {
baseEditor.classList.remove('opacity-25');
selectedIndex.textContent = selectedPair.index;
selectedBase.textContent = selectedPair.base;
complementaryBase.textContent = selectedPair.pair;
document.querySelectorAll('.base-button').forEach(btn => {
btn.classList.toggle('selected', btn.dataset.base === selectedPair.base);
});
} else {
baseEditor.classList.add('opacity-25');
selectedIndex.textContent = '-';
selectedBase.textContent = '-';
complementaryBase.textContent = '-';
document.querySelectorAll('.base-button').forEach(btn => btn.classList.remove('selected'));
}
}
function init3D() {
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(75, 1000 / 1000, 0.1, 1000);
camera.position.set(0, 0, 20);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(1000, 1000);
simContainer.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0xaaaaaa, 0.8));
const light = new THREE.PointLight(0xffffff, 1.2);
light.position.set(10, 10, 10);
scene.add(light);
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
}
function buildDNA() {
if (dnaGroup) scene.remove(dnaGroup);
dnaGroup = new THREE.Group();
interactiveObjects = [];
selectedPair = null;
updateEditorPanel();
const radius = 2;
const baseHeight = 0.6;
const totalHeight = dnaSequence.length * baseHeight;
const turns = dnaSequence.length / 10;
const backboneMaterial = new THREE.MeshStandardMaterial({ color: 0x888888 });
for (let i = 0; i < dnaSequence.length; i++) {
const y = -totalHeight / 2 + i * baseHeight;
const angle = (i / dnaSequence.length) * turns * 2 * Math.PI;
// sugar-phosphate backbone as spheres for click targets
const b1 = new THREE.Mesh(new THREE.SphereGeometry(0.2, 16, 16), backboneMaterial);
b1.position.set(radius * Math.cos(angle), y, radius * Math.sin(angle));
const b2 = new THREE.Mesh(new THREE.SphereGeometry(0.2, 16, 16), backboneMaterial);
b2.position.set(radius * Math.cos(angle + Math.PI), y, radius * Math.sin(angle + Math.PI));
dnaGroup.add(b1, b2);
const base1_char = dnaSequence[i];
const base2_char = BASE_PAIRS[base1_char];
const material1 = new THREE.MeshStandardMaterial({ color: BASE_COLORS[base1_char] });
const material2 = new THREE.MeshStandardMaterial({ color: BASE_COLORS[base2_char] });
const baseGeo = new THREE.CylinderGeometry(0.25, 0.25, 0.8, 16);
const mesh1 = new THREE.Mesh(baseGeo, material1);
mesh1.position.set((radius / 2) * Math.cos(angle), y, (radius / 2) * Math.sin(angle));
mesh1.rotation.z = Math.PI / 2;
const mesh2 = new THREE.Mesh(baseGeo, material2);
mesh2.position.set((radius / 2) * Math.cos(angle + Math.PI), y, (radius / 2) * Math.sin(angle + Math.PI));
mesh2.rotation.z = Math.PI / 2;
dnaGroup.add(mesh1, mesh2);
interactiveObjects.push({ base: base1_char, pair: base2_char, mesh1, mesh2, index: i });
}
scene.add(dnaGroup);
}
function onCanvasClick(event) {
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const meshes = interactiveObjects.flatMap(obj => [obj.mesh1, obj.mesh2]);
const intersects = raycaster.intersectObjects(meshes, true);
if (intersects.length > 0) {
const clickedMesh = intersects[0].object;
for (const pair of interactiveObjects) {
if (pair.mesh1 === clickedMesh || pair.mesh2 === clickedMesh) {
selectedPair = pair;
break;
}
}
} else {
selectedPair = null;
}
updateEditorPanel();
}
function handleBaseChange(newBase) {
if (!selectedPair) return;
const index = selectedPair.index;
const newPair = BASE_PAIRS[newBase];
dnaSequence = dnaSequence.substring(0, index) + newBase + dnaSequence.substring(index + 1);
selectedPair.base = newBase;
selectedPair.pair = newPair;
selectedPair.mesh1.material.color.setHex(BASE_COLORS[newBase]);
selectedPair.mesh2.material.color.setHex(BASE_COLORS[newPair]);
updateEditorPanel();
}
function animate3D() {
requestAnimationFrame(animate3D);
if (controls) controls.update();
if (dnaGroup) dnaGroup.rotation.y += 0.002;
renderer.render(scene, camera);
}
initialize();
</script>
</body>
</html>