electron_cloud_visualizer / js /gui-controller.js
AK51's picture
Upload 2 files
026b0d1 verified
/**
* 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;