Spaces:
Sleeping
Sleeping
| class BrownianSimulation { | |
| constructor(canvas) { | |
| this.canvas = canvas; | |
| this.ctx = canvas.getContext('2d'); | |
| this.width = canvas.width; | |
| this.height = canvas.height; | |
| // Load mosquito images | |
| this.mosquitoImage = new Image(); | |
| this.mosquitoImage.src = '/static/images/mosquito_1.png'; | |
| this.mosquitoSize = this.width * 0.03; // 3x actual mosquito size | |
| this.mosquitoType = 'mosquito_1'; // Default mosquito type | |
| // Set up mosquito selection | |
| this.setupMosquitoSelection(); | |
| // Load trap image | |
| this.trapImage = new Image(); | |
| this.trapImage.src = '/static/images/tim.png'; | |
| this.defaultTrapImageSrc = '/static/images/tim.png'; | |
| this.trapSize = this.width * 0.1; // Actual trap size | |
| // Set up trap image upload | |
| this.setupTrapImageUpload(); | |
| // Set up trap selection | |
| this.setupTrapSelection(); | |
| // Potential properties | |
| this.potentialCenter = { | |
| x: this.width / 2, | |
| y: this.height / 2 | |
| }; | |
| // Add click event listener | |
| this.canvas.addEventListener('click', (e) => { | |
| const rect = this.canvas.getBoundingClientRect(); | |
| this.potentialCenter.x = e.clientX - rect.left; | |
| this.potentialCenter.y = e.clientY - rect.top; | |
| }); | |
| // Remove these as they'll come from Python | |
| this.x = this.width / 2; | |
| this.y = this.height / 2; | |
| // Update API endpoint to be more specific | |
| const protocol = window.location.protocol; | |
| const host = window.location.host; | |
| this.apiUrl = `${protocol}//${host}/simulate_multiple_steps`; | |
| // Initialize error message element | |
| this.errorMessage = document.getElementById('error-message'); | |
| // Initialize particles array | |
| this.particles = []; | |
| // Store complete trajectory history for download | |
| this.completeTrajectoryData = []; | |
| this.trajectoryTimeOffset = 0; | |
| this.cueTypeHistory = []; // Track cue type changes over time | |
| this.lastCueType = 'visualco2'; // Default cue type | |
| // Add number of particles control | |
| this.numParticlesInput = document.getElementById('numParticles'); | |
| this.numParticlesValue = document.getElementById('numParticlesValue'); | |
| this.numParticlesInput.addEventListener('input', (e) => { | |
| this.numParticles = parseInt(e.target.value); | |
| this.numParticlesValue.textContent = this.numParticles; | |
| // Reset trajectory data when number of mosquitoes changes | |
| this.resetTrajectoryData(); | |
| }); | |
| this.numParticles = 3; // Start with 3 particles | |
| // Create and add warning message element | |
| this.warningMessage = document.createElement('div'); | |
| this.warningMessage.style.color = 'orange'; | |
| this.warningMessage.style.display = 'none'; | |
| this.warningMessage.style.position = 'absolute'; | |
| this.warningMessage.style.top = '10px'; | |
| this.warningMessage.style.left = '10px'; | |
| document.body.appendChild(this.warningMessage); | |
| this.amountOfDataToRequest = 1000; | |
| this.requestFrequency = 800; | |
| this.targetFrameTime = 50; // 50ms = 0.05 seconds | |
| this.lastFrameTime = 0; | |
| this.currentFrameIndex = 0; | |
| this.pendingParticles = null; | |
| this.lastDataRequestTime = 0; | |
| // Add model type control | |
| this.setupModelTypeSelection(); | |
| // Set up download trajectory functionality | |
| this.setupDownloadTrajectory(); | |
| } | |
| // Add this new method to handle mosquito selection | |
| setupMosquitoSelection() { | |
| const mosquito1 = document.getElementById('mosquito1'); | |
| if (mosquito1) { | |
| mosquito1.addEventListener('click', () => { | |
| this.selectMosquito('mosquito_1'); | |
| mosquito1.classList.add('selected'); | |
| }); | |
| } | |
| } | |
| selectMosquito(type) { | |
| this.mosquitoType = type; | |
| this.mosquitoImage.src = `/static/images/${type}.png`; | |
| } | |
| // Add this new method to handle trap image upload | |
| setupTrapImageUpload() { | |
| const trapImageUpload = document.getElementById('trapImageUpload'); | |
| const resetTrapImage = document.getElementById('resetTrapImage'); | |
| if (trapImageUpload && resetTrapImage) { | |
| trapImageUpload.addEventListener('change', (e) => { | |
| const file = e.target.files[0]; | |
| if (file) { | |
| const reader = new FileReader(); | |
| reader.onload = (event) => { | |
| this.trapImage.src = event.target.result; | |
| // Clear selection when custom image is uploaded | |
| this.clearTrapSelection(); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| }); | |
| resetTrapImage.addEventListener('click', () => { | |
| this.trapImage.src = this.defaultTrapImageSrc; | |
| trapImageUpload.value = ''; // Clear the file input | |
| // Select the default trap option | |
| this.selectTrap('tim'); | |
| }); | |
| } | |
| } | |
| // Add this new method to handle trap selection | |
| setupTrapSelection() { | |
| const trapTim = document.getElementById('trap-tim'); | |
| const trapCircle = document.getElementById('trap-circle'); | |
| if (trapTim && trapCircle) { | |
| trapTim.addEventListener('click', () => { | |
| this.selectTrap('tim'); | |
| }); | |
| trapCircle.addEventListener('click', () => { | |
| this.selectTrap('circle'); | |
| }); | |
| } | |
| } | |
| selectTrap(type) { | |
| // Clear all selections | |
| this.clearTrapSelection(); | |
| // Set the selected trap | |
| switch(type) { | |
| case 'tim': | |
| this.trapImage.src = '/static/images/tim.png'; | |
| document.getElementById('trap-tim').classList.add('selected'); | |
| break; | |
| case 'circle': | |
| this.trapImage.src = '/static/images/circle.png'; | |
| document.getElementById('trap-circle').classList.add('selected'); | |
| break; | |
| } | |
| // Clear any custom uploaded image | |
| const trapImageUpload = document.getElementById('trapImageUpload'); | |
| if (trapImageUpload) { | |
| trapImageUpload.value = ''; | |
| } | |
| } | |
| clearTrapSelection() { | |
| const trapOptions = document.querySelectorAll('.trap-option'); | |
| trapOptions.forEach(option => { | |
| option.classList.remove('selected'); | |
| }); | |
| } | |
| // Add this new method to handle trajectory download | |
| setupDownloadTrajectory() { | |
| const downloadBtn = document.getElementById('download-trajectory'); | |
| if (downloadBtn) { | |
| downloadBtn.addEventListener('click', () => { | |
| this.downloadTrajectoryCSV(); | |
| }); | |
| } | |
| } | |
| downloadTrajectoryCSV() { | |
| if (!this.completeTrajectoryData || this.completeTrajectoryData.length === 0) { | |
| alert('No trajectory data available. Please run a simulation first.'); | |
| return; | |
| } | |
| // Prepare CSV data | |
| let csvContent = 'Timepoint,MosquitoID,X,Y,VX,VY,CueType\n'; | |
| // Find the maximum number of timepoints across all particles | |
| const maxTimepoints = Math.max(...this.completeTrajectoryData.map(p => p.x.length)); | |
| // Generate timepoints based on the simulation parameters | |
| const timeStep = this.targetFrameTime / 1000; // Convert to seconds | |
| for (let t = 0; t < maxTimepoints; t++) { | |
| const timepoint = t * timeStep; | |
| // Find the cue type for this specific timepoint | |
| let cueTypeForTimepoint = 'visualco2'; // Default | |
| if (this.cueTypeHistory.length > 0) { | |
| // Find the cue type that was active at this timepoint | |
| const historyEntry = this.cueTypeHistory.find(entry => | |
| Math.abs(entry.timepoint - timepoint) < timeStep / 2 | |
| ); | |
| if (historyEntry) { | |
| cueTypeForTimepoint = historyEntry.cueType; | |
| } | |
| } | |
| for (let mosquitoId = 0; mosquitoId < this.completeTrajectoryData.length; mosquitoId++) { | |
| const particle = this.completeTrajectoryData[mosquitoId]; | |
| // Check if this timepoint exists for this particle | |
| if (t < particle.x.length) { | |
| const x = particle.x[t]; | |
| const y = particle.y[t]; | |
| const vx = particle.vx[t]; | |
| const vy = particle.vy[t]; | |
| csvContent += `${timepoint.toFixed(3)},${mosquitoId + 1},${x.toFixed(2)},${y.toFixed(2)},${vx.toFixed(2)},${vy.toFixed(2)},${cueTypeForTimepoint}\n`; | |
| } | |
| } | |
| } | |
| // Create and download the file | |
| const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); | |
| const link = document.createElement('a'); | |
| const url = URL.createObjectURL(blob); | |
| link.setAttribute('href', url); | |
| link.setAttribute('download', `mosquito_trajectory_complete_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`); | |
| link.style.visibility = 'hidden'; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| } | |
| // Add this new method to handle model type selection | |
| setupModelTypeSelection() { | |
| const modelOptions = document.querySelectorAll('.model-option'); | |
| if (modelOptions.length > 0) { | |
| modelOptions.forEach(option => { | |
| option.addEventListener('click', () => { | |
| // Remove selected class from all options | |
| modelOptions.forEach(opt => opt.classList.remove('selected')); | |
| // Add selected class to clicked option | |
| option.classList.add('selected'); | |
| // Update model type | |
| this.modelType = option.dataset.value; | |
| // Track cue type change for trajectory data | |
| if (this.lastCueType !== this.modelType) { | |
| this.lastCueType = this.modelType; | |
| } | |
| }); | |
| }); | |
| // Set initial model type from the selected option | |
| const selectedOption = document.querySelector('.model-option.selected'); | |
| if (selectedOption) { | |
| this.modelType = selectedOption.dataset.value; | |
| } | |
| } | |
| } | |
| // Update particle position | |
| async update() { | |
| try { | |
| const url = `${this.apiUrl}?centerX=${this.potentialCenter.x}¢erY=${this.potentialCenter.y}&numParticles=${this.numParticles}&model=${this.modelType}&amountOfDataToRequest=${this.amountOfDataToRequest}&targetFrameTime=${this.targetFrameTime}`; | |
| const response = await fetch(url, { | |
| method: 'GET' | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`HTTP error! status: ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| return data; // Return data instead of setting this.particles directly | |
| this.errorMessage.style.display = 'none'; | |
| } catch (error) { | |
| console.error('Error fetching simulation data:', error); | |
| this.errorMessage.textContent = `Cannot connect to simulation backend: ${error.message}`; | |
| this.errorMessage.style.display = 'block'; | |
| return null; | |
| } | |
| } | |
| // Draw the current state | |
| draw() { | |
| // Clear canvas | |
| this.ctx.clearRect(0, 0, this.width, this.height); | |
| // Draw trap (instead of red dot) | |
| if (this.trapImage.complete) { | |
| // Adjust trap size based on the current trap image | |
| let trapWidth = this.trapSize; | |
| let trapHeight = this.trapSize; | |
| // If it's the Tim trap, make height 1.6 times the width | |
| if (this.trapImage.src.includes('tim.png')) { | |
| trapHeight = this.trapSize * 1.6; | |
| } | |
| this.ctx.drawImage( | |
| this.trapImage, | |
| this.potentialCenter.x - trapWidth/2, | |
| this.potentialCenter.y - trapHeight/2, | |
| trapWidth, | |
| trapHeight | |
| ); | |
| } | |
| // Draw all mosquitos | |
| if (this.mosquitoImage.complete && this.particles && this.particles.length > 0) { | |
| for (const particle of this.particles) { | |
| this.ctx.save(); | |
| // Draw the current frame from the array of positions | |
| const currentIndex = Math.min(this.currentFrameIndex, particle.x.length - 1); | |
| this.ctx.translate(particle.x[currentIndex], particle.y[currentIndex]); | |
| // Use different rotation angle based on mosquito type | |
| const rotationAngle = this.mosquitoType === 'mosquito_1' ? Math.PI/2 : Math.PI; | |
| this.ctx.rotate(Math.atan2(particle.vy[currentIndex], particle.vx[currentIndex]) + rotationAngle); | |
| this.ctx.drawImage(this.mosquitoImage, -this.mosquitoSize/2, -this.mosquitoSize/2, | |
| this.mosquitoSize, this.mosquitoSize); | |
| this.ctx.restore(); | |
| } | |
| } | |
| } | |
| // Modify animate to handle precise timing and frame advancement | |
| async animate(currentTime) { | |
| // Calculate time since last frame | |
| const deltaTime = currentTime - this.lastFrameTime; | |
| // Check for slow frames and display warning | |
| if (deltaTime >= this.targetFrameTime * 1.5) { | |
| this.warningMessage.textContent = `Performance warning: Frame took ${Math.round(deltaTime)}ms (target: ${this.targetFrameTime}ms)`; | |
| this.warningMessage.style.display = 'block'; | |
| } else { | |
| this.warningMessage.style.display = 'none'; | |
| } | |
| if (deltaTime >= this.targetFrameTime) { | |
| // Draw current frame | |
| this.draw(); | |
| // Advance to next frame | |
| if (this.particles && this.particles.length > 0) { | |
| const maxFrames = this.particles[0].x.length; | |
| // Move to next frame or reset if at the end | |
| this.currentFrameIndex++; | |
| // If we've shown all frames from the current batch and have pending data | |
| if (this.currentFrameIndex >= maxFrames - 1 && this.pendingParticles) { | |
| // Add the current batch to complete trajectory before switching | |
| this.addToCompleteTrajectory(this.particles); | |
| // Switch to the pending data | |
| this.particles = this.pendingParticles; | |
| this.pendingParticles = null; | |
| this.currentFrameIndex = 0; | |
| } | |
| } | |
| this.lastFrameTime = currentTime; | |
| } | |
| requestAnimationFrame((timestamp) => this.animate(timestamp)); | |
| } | |
| async updateCallback() { | |
| // Only request new data if we don't already have pending data | |
| console.log(this.numParticles); | |
| if (!this.pendingParticles) { | |
| const newData = await this.update(); | |
| if (newData) { | |
| // Check if the number of mosquitoes has changed | |
| if (this.particles.length > 0 && newData.length !== this.particles.length) { | |
| // Number of mosquitoes changed, reset trajectory data | |
| this.resetTrajectoryData(); | |
| } | |
| // If we have no particles yet, set them directly | |
| if (!this.particles || this.particles.length === 0) { | |
| this.particles = newData; | |
| this.currentFrameIndex = 0; | |
| // Initialize complete trajectory data | |
| this.initializeCompleteTrajectory(newData); | |
| } else { | |
| // Otherwise, store as pending | |
| this.pendingParticles = newData; | |
| } | |
| } | |
| } | |
| } | |
| // Initialize complete trajectory data structure | |
| initializeCompleteTrajectory(newData) { | |
| this.completeTrajectoryData = []; | |
| this.trajectoryTimeOffset = 0; | |
| this.cueTypeHistory = []; | |
| this.lastCueType = this.modelType || 'visualco2'; | |
| for (let i = 0; i < newData.length; i++) { | |
| this.completeTrajectoryData.push({ | |
| x: [...newData[i].x], | |
| y: [...newData[i].y], | |
| vx: [...newData[i].vx], | |
| vy: [...newData[i].vy] | |
| }); | |
| } | |
| // Record the initial cue type for all timepoints in this batch | |
| const timepointsInBatch = newData[0].x.length; | |
| for (let t = 0; t < timepointsInBatch; t++) { | |
| this.cueTypeHistory.push({ | |
| timepoint: t * (this.targetFrameTime / 1000), | |
| cueType: this.lastCueType | |
| }); | |
| } | |
| } | |
| // Add new trajectory data to the complete history | |
| addToCompleteTrajectory(newData) { | |
| if (this.completeTrajectoryData.length === 0) { | |
| this.initializeCompleteTrajectory(newData); | |
| return; | |
| } | |
| // Check if the number of mosquitoes has changed significantly | |
| // If the change is more than just adding mosquitoes, reset the trajectory | |
| if (newData.length < this.completeTrajectoryData.length) { | |
| // Number of mosquitoes decreased, reset trajectory | |
| this.initializeCompleteTrajectory(newData); | |
| return; | |
| } | |
| // Check if cue type has changed | |
| const currentCueType = this.modelType || 'visualco2'; | |
| if (currentCueType !== this.lastCueType) { | |
| this.lastCueType = currentCueType; | |
| } | |
| // Ensure we have the same number of particles | |
| while (this.completeTrajectoryData.length < newData.length) { | |
| this.completeTrajectoryData.push({ | |
| x: [], | |
| y: [], | |
| vx: [], | |
| vy: [] | |
| }); | |
| } | |
| // Add new data points to each particle's trajectory | |
| for (let i = 0; i < newData.length; i++) { | |
| if (i < this.completeTrajectoryData.length) { | |
| // Add new data points | |
| this.completeTrajectoryData[i].x.push(...newData[i].x); | |
| this.completeTrajectoryData[i].y.push(...newData[i].y); | |
| this.completeTrajectoryData[i].vx.push(...newData[i].vx); | |
| this.completeTrajectoryData[i].vy.push(...newData[i].vy); | |
| } | |
| } | |
| // Add cue type history for this batch | |
| const timepointsInBatch = newData[0].x.length; | |
| const startTime = this.trajectoryTimeOffset; | |
| for (let t = 0; t < timepointsInBatch; t++) { | |
| this.cueTypeHistory.push({ | |
| timepoint: startTime + (t * (this.targetFrameTime / 1000)), | |
| cueType: this.lastCueType | |
| }); | |
| } | |
| // Update time offset for the next batch | |
| this.trajectoryTimeOffset += newData[0].x.length * (this.targetFrameTime / 1000); | |
| } | |
| // Reset trajectory data when number of mosquitoes changes | |
| resetTrajectoryData() { | |
| this.completeTrajectoryData = []; | |
| this.trajectoryTimeOffset = 0; | |
| this.cueTypeHistory = []; | |
| this.lastCueType = this.modelType || 'visualco2'; | |
| this.currentFrameIndex = 0; | |
| this.pendingParticles = null; | |
| this.particles = []; // Clear current particles as well | |
| } | |
| // todo: | |
| // - if user moves, call requestDataCallback() and updateCallback() | |
| } | |
| // Modify initialization | |
| window.onload = () => { | |
| const canvas = document.getElementById('simulation'); | |
| const simulation = new BrownianSimulation(canvas); | |
| // Initial data request | |
| simulation.updateCallback(); | |
| // Start animation | |
| requestAnimationFrame((timestamp) => simulation.animate(timestamp)); | |
| // Set up periodic data requests | |
| setInterval(() => simulation.updateCallback(), simulation.requestFrequency); | |
| // Proactively clean up the server-side session when the user leaves | |
| const sendCleanup = () => { | |
| const url = `${window.location.origin}/cleanup`; | |
| // Prefer sendBeacon for reliability during unload | |
| if (navigator.sendBeacon) { | |
| const blob = new Blob([], { type: 'application/octet-stream' }); | |
| navigator.sendBeacon(url, blob); | |
| } else { | |
| // Fallback to a synchronous XHR as a last resort | |
| try { | |
| const xhr = new XMLHttpRequest(); | |
| xhr.open('POST', url, false); | |
| xhr.setRequestHeader('Content-Type', 'text/plain'); | |
| xhr.send(''); | |
| } catch (e) { | |
| // ignore | |
| } | |
| } | |
| }; | |
| // Use multiple event listeners to ensure cleanup happens | |
| window.addEventListener('pagehide', sendCleanup); | |
| window.addEventListener('beforeunload', sendCleanup); | |
| window.addEventListener('unload', sendCleanup); | |
| document.addEventListener('visibilitychange', () => { | |
| if (document.visibilityState === 'hidden') { | |
| sendCleanup(); | |
| } | |
| }); | |
| }; |