Spaces:
Running
Running
| /** | |
| * GUI Controller | |
| * Manages user interface controls using lil-gui | |
| */ | |
| class GUIController { | |
| constructor(appController) { | |
| this.appController = appController; | |
| this.gui = null; | |
| this.folders = {}; | |
| this.orbitalControls = new Map(); | |
| this.isMobile = this.detectMobile(); | |
| } | |
| /** | |
| * Detect if device is mobile | |
| * @returns {boolean} | |
| */ | |
| detectMobile() { | |
| return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) | |
| || window.innerWidth <= 768; | |
| } | |
| /** | |
| * Initialize lil-gui interface | |
| */ | |
| initialize() { | |
| // Adjust GUI width based on device | |
| const guiWidth = this.isMobile ? Math.min(window.innerWidth - 20, 320) : 450; | |
| this.gui = new lil.GUI({ | |
| width: guiWidth, | |
| autoPlace: true | |
| }); | |
| this.gui.title('Electron Cloud Visualizer'); | |
| // Close all folders by default on mobile for better UX | |
| if (this.isMobile) { | |
| this.gui.close(); | |
| } | |
| this.createAtomControls(); | |
| this.createDisplayControls(); | |
| this.createCameraControls(); | |
| this.createCredits(); | |
| // Add mobile-specific styling | |
| if (this.isMobile) { | |
| this.applyMobileStyling(); | |
| } | |
| return this.gui; | |
| } | |
| /** | |
| * Apply mobile-specific styling to GUI | |
| */ | |
| applyMobileStyling() { | |
| const guiElement = this.gui.domElement; | |
| guiElement.style.position = 'fixed'; | |
| guiElement.style.top = '10px'; | |
| guiElement.style.right = '10px'; | |
| guiElement.style.maxHeight = 'calc(100vh - 20px)'; | |
| guiElement.style.overflowY = 'auto'; | |
| guiElement.style.zIndex = '1000'; | |
| } | |
| /** | |
| * Create Atom Settings controls | |
| */ | |
| createAtomControls() { | |
| const folder = this.gui.addFolder('Atom Settings'); | |
| this.folders.atom = folder; | |
| // Create a separate object for GUI binding | |
| const guiState = { | |
| atomicNumber: this.appController.state.atomicNumber | |
| }; | |
| // Atomic number slider (no auto-update) | |
| folder.add(guiState, 'atomicNumber', 1, CONSTANTS.MAX_ATOMIC_NUMBER, 1) | |
| .name('Atomic Number') | |
| .onChange((value) => { | |
| console.log('GUI: Atomic number slider moved to:', value); | |
| // Just update the element info, don't regenerate yet | |
| this.updateElementInfo(value); | |
| }); | |
| // Element display (read-only) | |
| const elementInfo = { element: '' }; | |
| this.elementController = folder.add(elementInfo, 'element') | |
| .name('Element') | |
| .listen() | |
| .disable(); | |
| // Electron configuration display (read-only) | |
| const configInfo = { configuration: '' }; | |
| this.configController = folder.add(configInfo, 'configuration') | |
| .name('Configuration') | |
| .listen() | |
| .disable(); | |
| // Update button for element configuration | |
| folder.add({ | |
| updateElement: () => { | |
| console.log('GUI: Update Element button clicked'); | |
| const newAtomicNumber = guiState.atomicNumber; | |
| // Show loading spinner immediately | |
| const loadingOverlay = document.getElementById('loading-overlay'); | |
| const loadingText = document.getElementById('loading-text'); | |
| if (loadingOverlay) loadingOverlay.style.display = 'flex'; | |
| if (loadingText) loadingText.textContent = 'Loading element...'; | |
| // Update element asynchronously | |
| this.appController.setAtomicNumber(newAtomicNumber).catch(err => { | |
| console.error('Error updating atomic number:', err); | |
| // Hide loading on error | |
| if (loadingOverlay) loadingOverlay.style.display = 'none'; | |
| }); | |
| } | |
| }, 'updateElement') | |
| .name('🔄 Update Element'); | |
| this.updateElementInfo(this.appController.state.atomicNumber); | |
| folder.open(); | |
| } | |
| /** | |
| * Create Display Options controls | |
| */ | |
| createDisplayControls() { | |
| const folder = this.gui.addFolder('Display Options'); | |
| this.folders.display = folder; | |
| const settings = this.appController.state.displaySettings; | |
| // Scale slider | |
| folder.add(settings, 'scale', 0.1, 3.0, 0.1) | |
| .name('Atom Scale') | |
| .onChange((value) => { | |
| this.appController.setScale(value); | |
| }); | |
| // Particle count slider | |
| folder.add(settings, 'particleCount', | |
| CONSTANTS.MIN_PARTICLE_COUNT, | |
| CONSTANTS.MAX_PARTICLE_COUNT, | |
| 1000) | |
| .name('Particle Count') | |
| .onChange(() => { | |
| this.appController.updateVisualization(); | |
| }); | |
| // Particle size slider | |
| folder.add(settings, 'particleSize', 0.01, 1.0, 0.01) | |
| .name('Particle Size') | |
| .onChange((value) => { | |
| this.updateParticleSize(value); | |
| }); | |
| // Opacity slider | |
| folder.add(settings, 'opacity', 0.1, 1.0, 0.1) | |
| .name('Opacity') | |
| .onChange((value) => { | |
| this.updateParticleOpacity(value); | |
| }); | |
| // Animation enabled checkbox | |
| folder.add(settings, 'animationEnabled') | |
| .name('Enable Animation') | |
| .onChange((value) => { | |
| this.appController.setAnimationEnabled(value); | |
| }); | |
| // Animation speed slider | |
| folder.add(settings, 'animationSpeed', 0.0, 5.0, 0.1) | |
| .name('Animation Speed') | |
| .onChange((value) => { | |
| this.appController.setAnimationSpeed(value); | |
| }); | |
| // Color hue gradient option | |
| folder.add(settings, 'hueGradientEnabled') | |
| .name('Color Hue Gradient') | |
| .onChange((value) => { | |
| this.appController.setHueGradientEnabled(value); | |
| }); | |
| folder.add(settings, 'hueGradientIntensity', 0, 360, 10) | |
| .name('Hue Gradient Intensity') | |
| .onChange((value) => { | |
| this.appController.setHueGradientIntensity(value); | |
| }); | |
| // Clipping plane section | |
| folder.add(settings, 'clippingEnabled') | |
| .name('Enable Clipping') | |
| .onChange((value) => { | |
| this.appController.setClippingEnabled(value); | |
| }); | |
| this.xClipController = folder.add(settings, 'xClipPosition', -20, 20, 0.1) | |
| .name('X Clip Position') | |
| .onChange((value) => { | |
| this.appController.setXClipPosition(value); | |
| }); | |
| this.yClipController = folder.add(settings, 'yClipPosition', -20, 20, 0.1) | |
| .name('Y Clip Position') | |
| .onChange((value) => { | |
| this.appController.setYClipPosition(value); | |
| }); | |
| this.zClipController = folder.add(settings, 'zClipPosition', -20, 20, 0.1) | |
| .name('Z Clip Position') | |
| .onChange((value) => { | |
| this.appController.setZClipPosition(value); | |
| }); | |
| // Coordinate Axes section | |
| const axesSettings = { | |
| showXAxis: true, | |
| showYAxis: true, | |
| showZAxis: true | |
| }; | |
| folder.add(axesSettings, 'showXAxis') | |
| .name('Show X-Axis (Red)') | |
| .onChange((value) => { | |
| this.appController.setAxisVisibility('x', value); | |
| }); | |
| folder.add(axesSettings, 'showYAxis') | |
| .name('Show Y-Axis (Green)') | |
| .onChange((value) => { | |
| this.appController.setAxisVisibility('y', value); | |
| }); | |
| folder.add(axesSettings, 'showZAxis') | |
| .name('Show Z-Axis (Blue)') | |
| .onChange((value) => { | |
| this.appController.setAxisVisibility('z', value); | |
| }); | |
| folder.open(); | |
| } | |
| /** | |
| * Create Camera controls | |
| */ | |
| createCameraControls() { | |
| const folder = this.gui.addFolder('Camera'); | |
| this.folders.camera = folder; | |
| const settings = this.appController.state.cameraSettings; | |
| // Reset view button | |
| folder.add({ reset: () => this.appController.resetCamera() }, 'reset') | |
| .name('Reset View'); | |
| // Auto-rotate toggle | |
| folder.add(settings, 'autoRotate') | |
| .name('Auto Rotate') | |
| .onChange((value) => { | |
| this.appController.setAutoRotate(value); | |
| }); | |
| // Rotation axis selector | |
| folder.add(settings, 'rotationAxis', ['x', 'y', 'z']) | |
| .name('Rotation Axis') | |
| .onChange((value) => { | |
| this.appController.setRotationAxis(value); | |
| }); | |
| // Rotation speed slider | |
| folder.add(settings, 'rotationSpeed', 0.1, 2.0, 0.1) | |
| .name('Rotation Speed') | |
| .onChange((value) => { | |
| this.appController.setRotationSpeed(value); | |
| }); | |
| folder.open(); | |
| } | |
| /** | |
| * Create Orbital Selection controls | |
| * @param {Array<Orbital>} orbitals | |
| */ | |
| createOrbitalControls(orbitals) { | |
| // Remove existing orbital folder if it exists | |
| if (this.folders.orbitals) { | |
| this.folders.orbitals.destroy(); | |
| this.folders.orbitals = null; | |
| } | |
| const folder = this.gui.addFolder('Orbital Selection'); | |
| this.folders.orbitals = folder; | |
| this.orbitalControls.clear(); | |
| // Show All checkbox (unchecked by default) | |
| const showAllState = { showAll: false }; | |
| folder.add(showAllState, 'showAll') | |
| .name('Show All') | |
| .onChange((value) => { | |
| orbitals.forEach(orbital => { | |
| orbital.visible = value; | |
| const controller = this.orbitalControls.get(orbital.getId()); | |
| if (controller) { | |
| controller.setValue(value); | |
| } | |
| }); | |
| this.appController.updateVisualization(); | |
| }); | |
| // Group orbitals by designation (1s, 2s, 2p, etc.) | |
| const orbitalGroups = new Map(); | |
| orbitals.forEach(orbital => { | |
| const designation = orbital.getDesignation(); | |
| if (!orbitalGroups.has(designation)) { | |
| orbitalGroups.set(designation, []); | |
| } | |
| orbitalGroups.get(designation).push(orbital); | |
| }); | |
| // Create controls for each orbital group | |
| orbitalGroups.forEach((group, designation) => { | |
| const orbitalType = group[0].getType(); | |
| // Sort by m value for consistent ordering | |
| group.sort((a, b) => a.m - b.m); | |
| // Check if any orbital in this group is visible | |
| const hasVisibleOrbital = group.some(orbital => orbital.visible); | |
| // Create a collapsible folder for this shell (e.g., "1s", "2p", "3d") | |
| const shellFolder = folder.addFolder(designation); | |
| // Add individual orbital controls inside the shell folder | |
| group.forEach(orbital => { | |
| const mLabel = this.getMLabel(orbital.l, orbital.m); | |
| // Visibility checkbox | |
| const state = { visible: orbital.visible }; | |
| const controller = shellFolder.add(state, 'visible') | |
| .name(mLabel ? `${mLabel}` : 'Show') | |
| .onChange((value) => { | |
| orbital.visible = value; | |
| this.appController.updateVisualization(); | |
| }); | |
| this.orbitalControls.set(orbital.getId(), controller); | |
| // Create a collapsible subfolder for color picker | |
| const colorFolder = shellFolder.addFolder(mLabel ? ` └ ${mLabel} Color` : ' └ Color'); | |
| // Color picker inside the subfolder | |
| const colorState = { | |
| color: orbital.color ? this.rgbToHex(orbital.color) : this.rgbToHex(CONSTANTS.ORBITAL_COLORS[orbitalType]) | |
| }; | |
| colorFolder.addColor(colorState, 'color') | |
| .name('Color') | |
| .onChange((value) => { | |
| orbital.color = this.hexToRgb(value); | |
| this.appController.updateVisualization(); | |
| }); | |
| // Close the color folder by default | |
| colorFolder.close(); | |
| }); | |
| // If any orbital in this shell is visible, expand the shell folder | |
| if (hasVisibleOrbital) { | |
| shellFolder.open(); | |
| } else { | |
| shellFolder.close(); | |
| } | |
| }); | |
| folder.open(); | |
| } | |
| /** | |
| * Get label for m quantum number | |
| * @param {number} l - Azimuthal quantum number | |
| * @param {number} m - Magnetic quantum number | |
| * @returns {string} Label for the orbital | |
| */ | |
| getMLabel(l, m) { | |
| // For p orbitals (l=1) | |
| // Standard mapping for real spherical harmonics: | |
| // m = 0: pz (aligned with z-axis) | |
| // m = ±1: px and py (perpendicular to z-axis) | |
| if (l === 1) { | |
| if (m === -1) return ' (py)'; | |
| if (m === 0) return ' (pz)'; | |
| if (m === 1) return ' (px)'; | |
| } | |
| // For d orbitals (l=2) | |
| else if (l === 2) { | |
| if (m === -2) return ' (dxy)'; | |
| if (m === -1) return ' (dxz)'; | |
| if (m === 0) return ' (dz²)'; | |
| if (m === 1) return ' (dyz)'; | |
| if (m === 2) return ' (dx²-y²)'; | |
| } | |
| // For f orbitals (l=3) | |
| else if (l === 3) { | |
| // Simplified f orbital labels | |
| return ` (m=${m})`; | |
| } | |
| return ` (m=${m})`; | |
| } | |
| /** | |
| * Convert RGB object to hex color string | |
| * @param {Object} rgb - {r, g, b} with values 0-1 | |
| * @returns {string} Hex color string | |
| */ | |
| rgbToHex(rgb) { | |
| const r = Math.round(rgb.r * 255); | |
| const g = Math.round(rgb.g * 255); | |
| const b = Math.round(rgb.b * 255); | |
| return '#' + [r, g, b].map(x => { | |
| const hex = x.toString(16); | |
| return hex.length === 1 ? '0' + hex : hex; | |
| }).join(''); | |
| } | |
| /** | |
| * Convert hex color string to RGB object | |
| * @param {string} hex - Hex color string | |
| * @returns {Object} {r, g, b} with values 0-1 | |
| */ | |
| hexToRgb(hex) { | |
| const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); | |
| return result ? { | |
| r: parseInt(result[1], 16) / 255, | |
| g: parseInt(result[2], 16) / 255, | |
| b: parseInt(result[3], 16) / 255 | |
| } : { r: 1, g: 1, b: 1 }; | |
| } | |
| /** | |
| * Update element information display | |
| * @param {number} atomicNumber | |
| */ | |
| updateElementInfo(atomicNumber) { | |
| const config = new ElectronConfiguration(atomicNumber); | |
| config.build(); | |
| const info = `${config.elementSymbol} - ${config.elementName}`; | |
| const configString = config.getConfigurationString(); | |
| if (this.elementController) { | |
| this.elementController.object.element = info; | |
| } | |
| if (this.configController) { | |
| this.configController.object.configuration = configString; | |
| } | |
| } | |
| /** | |
| * Update particle size for all orbitals | |
| * @param {number} size | |
| */ | |
| updateParticleSize(size) { | |
| const renderer = this.appController.renderer; | |
| renderer.orbitals.forEach(particleSystem => { | |
| particleSystem.material.size = size; | |
| }); | |
| } | |
| /** | |
| * Update particle opacity for all orbitals | |
| * @param {number} opacity | |
| */ | |
| updateParticleOpacity(opacity) { | |
| const renderer = this.appController.renderer; | |
| renderer.orbitals.forEach(particleSystem => { | |
| particleSystem.material.opacity = opacity; | |
| }); | |
| } | |
| /** | |
| * Create Credits section | |
| */ | |
| createCredits() { | |
| const folder = this.gui.addFolder('About'); | |
| this.folders.credits = folder; | |
| // Credit information (read-only) | |
| const creditInfo = { | |
| creator: 'Andy Kong', | |
| version: 'v1.0' | |
| }; | |
| folder.add(creditInfo, 'creator') | |
| .name('Created by') | |
| .listen() | |
| .disable(); | |
| folder.add(creditInfo, 'version') | |
| .name('Version') | |
| .listen() | |
| .disable(); | |
| folder.close(); | |
| } | |
| /** | |
| * Destroy GUI | |
| */ | |
| destroy() { | |
| if (this.gui) { | |
| this.gui.destroy(); | |
| this.gui = null; | |
| } | |
| } | |
| /** | |
| * Update clip position sliders | |
| * @param {number} x - X clip position | |
| * @param {number} y - Y clip position | |
| * @param {number} z - Z clip position | |
| */ | |
| updateClipPositions(x, y, z) { | |
| if (this.xClipController) { | |
| this.xClipController.setValue(x); | |
| } | |
| if (this.yClipController) { | |
| this.yClipController.setValue(y); | |
| } | |
| if (this.zClipController) { | |
| this.zClipController.setValue(z); | |
| } | |
| } | |
| } | |
| // Make class available globally | |
| window.GUIController = GUIController; | |