Spaces:
Running on Zero
Running on Zero
| // Cyclic Voltammetry Parameter Extraction & Physical Model Fitting | |
| // High-Performance JAX Auto-Diff Engine Client | |
| // Global State | |
| let expPotential = []; | |
| let expCurrent = []; | |
| let latestResults = null; | |
| let stagedFileContent = null; | |
| let stagedFileName = "No file selected"; | |
| let detectedColumns = []; | |
| // Global Modal Handler | |
| window.toggleModal = function(modalId, show) { | |
| const modal = document.getElementById(modalId); | |
| if (modal) { | |
| if (show) { | |
| modal.classList.remove('hidden'); | |
| } else { | |
| modal.classList.add('hidden'); | |
| } | |
| } | |
| }; | |
| // Global Advanced Settings Toggle | |
| window.toggleAdvanced = function() { | |
| const advToggle = document.getElementById('advanced-toggle'); | |
| const advContent = document.getElementById('advanced-content'); | |
| const toggleIcon = document.getElementById('toggle-icon'); | |
| if (advContent) { | |
| const isHidden = advContent.classList.toggle('hidden'); | |
| if (advToggle) advToggle.setAttribute('aria-expanded', !isHidden); | |
| if (toggleIcon) { | |
| toggleIcon.style.transform = isHidden ? 'rotate(0deg)' : 'rotate(180deg)'; | |
| } | |
| } | |
| }; | |
| // Delimiter Detection | |
| function detectDelimiter(line) { | |
| const commas = (line.match(/,/g) || []).length; | |
| const tabs = (line.match(/\t/g) || []).length; | |
| const semicolons = (line.match(/;/g) || []).length; | |
| if (tabs > commas && tabs > semicolons) return '\t'; | |
| if (semicolons > commas && semicolons > tabs) return ';'; | |
| return ','; | |
| } | |
| // Generalized 2-Column CSV Analysis & Dropdown Populator | |
| function analyzeCSVAndPopulateColumns(content) { | |
| const lines = content.split(/\r?\n/).filter(l => l.trim() && !l.trim().startsWith('#') && !l.trim().startsWith('//')); | |
| if (lines.length === 0) return; | |
| const delimiter = detectDelimiter(lines[0]); | |
| const firstLineFields = lines[0].split(delimiter).map(s => s.trim()); | |
| const secondLineFields = lines.length > 1 ? lines[1].split(delimiter).map(s => s.trim()) : []; | |
| let hasHeader = false; | |
| if (firstLineFields.some(f => isNaN(parseFloat(f)) && f.length > 0)) { | |
| hasHeader = true; | |
| } | |
| const colCount = hasHeader ? firstLineFields.length : (secondLineFields.length || firstLineFields.length); | |
| detectedColumns = []; | |
| const colCounts = new Array(colCount).fill(0); | |
| const startRow = hasHeader ? 1 : 0; | |
| for (let i = startRow; i < lines.length; i++) { | |
| const tokens = lines[i].split(delimiter); | |
| for (let c = 0; c < colCount; c++) { | |
| if (c < tokens.length) { | |
| const s = tokens[c].trim(); | |
| if (s !== "" && !isNaN(parseFloat(s))) { | |
| colCounts[c]++; | |
| } | |
| } | |
| } | |
| } | |
| let defaultPot = 0; | |
| let defaultCur = colCount > 1 ? 1 : 0; | |
| for (let c = 0; c < colCount; c++) { | |
| let rawHeader = hasHeader && firstLineFields[c] ? firstLineFields[c] : `Column ${c}`; | |
| let cleanHeader = rawHeader.replace(/["']/g, ''); | |
| let ptsStr = ` (${colCounts[c].toLocaleString()} pts)`; | |
| let displayName = `${cleanHeader}${ptsStr}`; | |
| detectedColumns.push({ | |
| index: c, | |
| name: displayName, | |
| rawName: cleanHeader, | |
| count: colCounts[c] | |
| }); | |
| // Smart column auto-detection based on header text | |
| const lower = cleanHeader.toLowerCase(); | |
| if (lower.includes('potential') || lower.includes('volt') || lower === 'v' || lower.includes('(v)')) { | |
| defaultPot = c; | |
| } else if (lower.includes('current') || lower.includes('curr') || lower === 'i' || lower.includes('(a)') || lower.includes('amp')) { | |
| defaultCur = c; | |
| } | |
| } | |
| const potSelect = document.getElementById('pot_col'); | |
| const curSelect = document.getElementById('cur_col'); | |
| const metaBar = document.getElementById('column-meta-bar'); | |
| const metaText = document.getElementById('detected-columns-text'); | |
| if (potSelect && curSelect) { | |
| potSelect.innerHTML = ''; | |
| curSelect.innerHTML = ''; | |
| detectedColumns.forEach(col => { | |
| const optP = document.createElement('option'); | |
| optP.value = col.index; | |
| optP.textContent = `[Col ${col.index}] ${col.name}`; | |
| potSelect.appendChild(optP); | |
| const optC = document.createElement('option'); | |
| optC.value = col.index; | |
| optC.textContent = `[Col ${col.index}] ${col.name}`; | |
| curSelect.appendChild(optC); | |
| }); | |
| potSelect.value = defaultPot; | |
| curSelect.value = defaultCur; | |
| if (metaBar && metaText) { | |
| metaBar.classList.add('visible'); | |
| metaText.innerHTML = `Loaded <strong>${colCount} column${colCount > 1 ? 's' : ''}</strong> • <strong>${lines.length - startRow} rows</strong>`; | |
| } | |
| window.updateLivePreviewFromColumns(); | |
| } | |
| } | |
| // Global Live Preview & Baseline Plotter | |
| window.updateLivePreviewFromColumns = function() { | |
| if (!stagedFileContent) return; | |
| const potSelect = document.getElementById('pot_col'); | |
| const curSelect = document.getElementById('cur_col'); | |
| if (!potSelect || !curSelect) return; | |
| const potCol = parseInt(potSelect.value, 10); | |
| const curCol = parseInt(curSelect.value, 10); | |
| const lines = stagedFileContent.split(/\r?\n/).filter(l => l.trim() && !l.trim().startsWith('#') && !l.trim().startsWith('//')); | |
| if (lines.length === 0) return; | |
| const delimiter = detectDelimiter(lines[0]); | |
| const firstTokens = lines[0].split(delimiter).map(t => t.trim()); | |
| let startIndex = 0; | |
| if (firstTokens.length > Math.max(potCol, curCol)) { | |
| if (isNaN(parseFloat(firstTokens[potCol])) || isNaN(parseFloat(firstTokens[curCol]))) { | |
| startIndex = 1; | |
| } | |
| } | |
| const previewPot = []; | |
| const previewCur = []; | |
| for (let i = startIndex; i < lines.length; i++) { | |
| const tokens = lines[i].split(delimiter); | |
| if (tokens.length > Math.max(potCol, curCol)) { | |
| const vStr = tokens[potCol].trim(); | |
| const cStr = tokens[curCol].trim(); | |
| if (vStr !== "" && cStr !== "") { | |
| const v = parseFloat(vStr); | |
| const c = parseFloat(cStr); | |
| if (!isNaN(v) && !isNaN(c)) { | |
| previewPot.push(v); | |
| previewCur.push(c); | |
| } | |
| } | |
| } | |
| } | |
| if (previewPot.length > 0) { | |
| expPotential = previewPot; | |
| expCurrent = previewCur; | |
| const vMin = Math.min(...previewPot); | |
| const vMax = Math.max(...previewPot); | |
| const iMin = Math.min(...previewCur); | |
| const iMax = Math.max(...previewCur); | |
| const vMinInput = document.getElementById('v_min'); | |
| const vMaxInput = document.getElementById('v_max'); | |
| if (vMinInput && vMaxInput) { | |
| vMinInput.value = vMin.toFixed(3); | |
| vMaxInput.value = vMax.toFixed(3); | |
| } | |
| const vRangeSpan = document.getElementById('stat-v-range'); | |
| const iRangeSpan = document.getElementById('stat-i-range'); | |
| const ptsSpan = document.getElementById('stat-points-count'); | |
| const statsBox = document.getElementById('col-stats-preview'); | |
| if (vRangeSpan) vRangeSpan.innerText = `${vMin.toFixed(3)} V to ${vMax.toFixed(3)} V`; | |
| if (iRangeSpan) iRangeSpan.innerText = `${iMin.toExponential(2)} A to ${iMax.toExponential(2)} A`; | |
| if (ptsSpan) ptsSpan.innerText = `${previewPot.length.toLocaleString()}`; | |
| if (statsBox) statsBox.classList.add('visible'); | |
| const statusDetails = document.getElementById('status-details'); | |
| if (statusDetails) { | |
| statusDetails.innerHTML = `Loaded <strong>${stagedFileName}</strong> • Potential (Col ${potCol}) & Current (Col ${curCol}) • ${previewPot.length.toLocaleString()} points ready for optimization.`; | |
| } | |
| renderInitialExpPlot(previewPot, previewCur); | |
| } | |
| }; | |
| function setLoadedFile(content, name) { | |
| stagedFileContent = content; | |
| stagedFileName = name; | |
| const fileNameDisplay = document.getElementById('file-name-display'); | |
| if (fileNameDisplay) { | |
| fileNameDisplay.innerText = name; | |
| fileNameDisplay.classList.add('has-file'); | |
| } | |
| analyzeCSVAndPopulateColumns(content); | |
| } | |
| // Global File Input Handlers | |
| window.handleCSVFileChange = function(input) { | |
| if (!input || !input.files || input.files.length === 0) return; | |
| const file = input.files[0]; | |
| const reader = new FileReader(); | |
| reader.onload = function(ev) { | |
| setLoadedFile(ev.target.result, file.name); | |
| }; | |
| reader.readAsText(file); | |
| }; | |
| window.handleCSVDrop = function(event) { | |
| if (!event || !event.dataTransfer || !event.dataTransfer.files || event.dataTransfer.files.length === 0) return; | |
| const file = event.dataTransfer.files[0]; | |
| const reader = new FileReader(); | |
| reader.onload = function(ev) { | |
| setLoadedFile(ev.target.result, file.name); | |
| }; | |
| reader.readAsText(file); | |
| }; | |
| // Helpers for Gradio Element Discovery and Value Setting | |
| function findGradioElement(selector) { | |
| let el = document.querySelector(selector); | |
| if (el) return el; | |
| const grApp = document.querySelector('gradio-app'); | |
| if (grApp && grApp.shadowRoot) { | |
| return grApp.shadowRoot.querySelector(selector); | |
| } | |
| return null; | |
| } | |
| function setGradioInputValue(containerSelector, val) { | |
| const container = findGradioElement(containerSelector); | |
| if (!container) return false; | |
| const input = container.querySelector('textarea, input') || container; | |
| try { | |
| const proto = Object.getPrototypeOf(input); | |
| const desc = Object.getOwnPropertyDescriptor(proto, 'value'); | |
| if (desc && desc.set) { | |
| desc.set.call(input, val); | |
| } else { | |
| input.value = val; | |
| } | |
| } catch (e) { | |
| input.value = val; | |
| } | |
| input.dispatchEvent(new Event('input', { bubbles: true, composed: true })); | |
| input.dispatchEvent(new Event('change', { bubbles: true, composed: true })); | |
| return true; | |
| } | |
| // Global Form Submit Handler | |
| window.handleFormSubmit = async function(e) { | |
| if (e && e.preventDefault) e.preventDefault(); | |
| if (!stagedFileContent) { | |
| alert('Please select and upload a cyclic voltammetry CSV data file first.'); | |
| return false; | |
| } | |
| const cvForm = document.getElementById('cv-form'); | |
| const formData = new FormData(cvForm); | |
| const config = {}; | |
| formData.forEach((value, key) => { | |
| config[key] = value; | |
| }); | |
| config.pot_col = parseInt(document.getElementById('pot_col').value, 10); | |
| config.cur_col = parseInt(document.getElementById('cur_col').value, 10); | |
| startOptimizationUI(); | |
| executeZeroGPUSolver(stagedFileContent, config); | |
| return false; | |
| }; | |
| // Execution via Native ZeroGPU Pipeline & Direct HTTP API | |
| async function executeZeroGPUSolver(fileContent, config) { | |
| const stageEl = document.getElementById('status-stage'); | |
| const detailsEl = document.getElementById('status-details'); | |
| if (stageEl) stageEl.innerText = '⚡ Optimizing Physical Model Parameters...'; | |
| if (detailsEl) detailsEl.innerText = 'Executing multi-stage non-linear L-BFGS-B optimization on JAX auto-diff engine...'; | |
| // 1. Native Gradio ZeroGPU Queue Trigger | |
| const fileSet = setGradioInputValue('#gr_input_file', fileContent); | |
| const configSet = setGradioInputValue('#gr_input_config', JSON.stringify(config)); | |
| const grBtn = findGradioElement('#gr_trigger_btn button') || findGradioElement('#gr_trigger_btn'); | |
| if (fileSet && configSet && grBtn) { | |
| const startTime = Date.now(); | |
| setGradioInputValue('#gr_output_json', ''); | |
| const pollInterval = setInterval(() => { | |
| const outContainer = findGradioElement('#gr_output_json'); | |
| const outEl = outContainer ? (outContainer.querySelector('textarea, input') || outContainer) : null; | |
| const textVal = (outEl ? outEl.value : "") || (outContainer ? outContainer.innerText : ""); | |
| if (textVal && textVal.trim().startsWith('{') && textVal.trim().endsWith('}')) { | |
| clearInterval(pollInterval); | |
| try { | |
| const data = JSON.parse(textVal.trim()); | |
| handleSolverMessage(data); | |
| } catch (e) { | |
| handleSolverError(`Failed to parse output JSON: ${e.message}`); | |
| } | |
| return; | |
| } | |
| const elapsedSec = Math.floor((Date.now() - startTime) / 1000); | |
| if (stageEl) stageEl.innerText = `⚡ Non-Linear Parameter Extraction (${elapsedSec}s)...`; | |
| if (detailsEl) detailsEl.innerText = `Solving 1D diffusion PDE and optimizing Fermi-Dirac DOS sub-bands...`; | |
| if (Date.now() - startTime > 180000) { | |
| clearInterval(pollInterval); | |
| handleSolverError("Optimization calculation timed out (3 min)."); | |
| } | |
| }, 500); | |
| grBtn.click(); | |
| return; | |
| } | |
| // 2. Direct HTTP POST fallback | |
| const endpoints = [ | |
| window.location.origin + "/api/solve", | |
| window.location.origin + "/solve", | |
| "http://127.0.0.1:8000/api/solve" | |
| ]; | |
| for (const endpoint of endpoints) { | |
| try { | |
| const controller = new AbortController(); | |
| const timeoutId = setTimeout(() => controller.abort(), 180000); | |
| const res = await fetch(endpoint, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| file_content: fileContent, | |
| config: config | |
| }), | |
| signal: controller.signal | |
| }); | |
| clearTimeout(timeoutId); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| handleSolverMessage(data); | |
| return; | |
| } | |
| } catch (err) { | |
| console.warn(`HTTP solve attempt on ${endpoint} failed:`, err); | |
| } | |
| } | |
| handleSolverError("Could not communicate with solver engine. Please verify the space is running."); | |
| } | |
| function handleSolverMessage(data) { | |
| const stageEl = document.getElementById('status-stage'); | |
| const detailsEl = document.getElementById('status-details'); | |
| if (data.type === 'done') { | |
| if (stageEl) stageEl.innerText = '✓ Physical Model Parameters Successfully Extracted'; | |
| if (detailsEl) detailsEl.innerText = `Optimization converged in ${data.total_iterations || 100} iterations. Model fit overlay and diagnostic spectra rendered below.`; | |
| stopOptimizationUI(); | |
| latestResults = data; | |
| displayExtractedResults(data); | |
| // Update primary plot with simulation overlay | |
| if (data.plots && data.plots.sim_current && window.Plotly) { | |
| updateLivePlotProgress({ | |
| potential: data.plots.exp_potential, | |
| current: data.plots.sim_current | |
| }); | |
| } | |
| } else if (data.type === 'error') { | |
| handleSolverError(data.message || 'An error occurred during calculation.'); | |
| } | |
| } | |
| function handleSolverError(msg) { | |
| const stageEl = document.getElementById('status-stage'); | |
| const detailsEl = document.getElementById('status-details'); | |
| if (stageEl) stageEl.innerText = '❌ Calculation Notice'; | |
| if (detailsEl) detailsEl.innerText = msg; | |
| stopOptimizationUI(); | |
| alert(`Solver Message: ${msg}`); | |
| } | |
| function startOptimizationUI() { | |
| const spinner = document.getElementById('status-spinner'); | |
| const submitBtn = document.getElementById('submit-btn'); | |
| if (spinner) spinner.classList.remove('hidden'); | |
| if (submitBtn) { | |
| submitBtn.disabled = true; | |
| submitBtn.innerText = 'Extracting Parameters...'; | |
| } | |
| } | |
| function stopOptimizationUI() { | |
| const spinner = document.getElementById('status-spinner'); | |
| const submitBtn = document.getElementById('submit-btn'); | |
| if (spinner) spinner.classList.add('hidden'); | |
| if (submitBtn) { | |
| submitBtn.disabled = false; | |
| submitBtn.innerText = 'Execute Optimization'; | |
| } | |
| } | |
| // Scientific Academic Plotly Layout Configuration | |
| const layoutConfig = { | |
| paper_bgcolor: 'transparent', | |
| plot_bgcolor: 'transparent', | |
| font: { family: 'Inter, -apple-system, sans-serif', color: '#94a3b8', size: 12 }, | |
| margin: { l: 75, r: 35, t: 40, b: 55 }, | |
| xaxis: { | |
| gridcolor: 'rgba(255, 255, 255, 0.07)', | |
| zerolinecolor: 'rgba(255, 255, 255, 0.15)', | |
| tickfont: { color: '#94a3b8', size: 11 }, | |
| titlefont: { color: '#f1f5f9', size: 13 } | |
| }, | |
| yaxis: { | |
| gridcolor: 'rgba(255, 255, 255, 0.07)', | |
| zerolinecolor: 'rgba(255, 255, 255, 0.15)', | |
| tickfont: { color: '#94a3b8', size: 11 }, | |
| titlefont: { color: '#f1f5f9', size: 13 }, | |
| tickformat: '.2e' | |
| } | |
| }; | |
| function renderInitialExpPlot(pot, cur) { | |
| if (!window.Plotly) return; | |
| const traceExp = { | |
| x: pot, | |
| y: cur, | |
| mode: 'lines', | |
| type: 'scatter', | |
| name: 'Experimental Voltammogram', | |
| line: { color: '#38bdf8', width: 2.2 } | |
| }; | |
| const layout = Object.assign({}, layoutConfig, { | |
| title: { text: `Cyclic Voltammogram (${stagedFileName})`, font: { color: '#ffffff', size: 14 } }, | |
| xaxis: Object.assign({}, layoutConfig.xaxis, { title: 'Applied Potential <i>V</i> (V vs. Ref)' }), | |
| yaxis: Object.assign({}, layoutConfig.yaxis, { title: 'Current <i>I</i> (A)' }), | |
| showlegend: true, | |
| legend: { x: 0.02, y: 0.98, bgcolor: 'rgba(15, 23, 42, 0.8)', font: { color: '#f1f5f9' } } | |
| }); | |
| Plotly.react('live-chart', [traceExp], layout, { responsive: true, displaylogo: false }); | |
| } | |
| function updateLivePlotProgress(currentFit) { | |
| if (!window.Plotly) return; | |
| const traceExp = { | |
| x: expPotential, | |
| y: expCurrent, | |
| mode: 'lines', | |
| type: 'scatter', | |
| name: 'Experimental Data', | |
| line: { color: '#38bdf8', width: 2.2 } | |
| }; | |
| const traceSim = { | |
| x: currentFit.potential || expPotential, | |
| y: currentFit.current, | |
| mode: 'lines', | |
| type: 'scatter', | |
| name: 'Fitted Physical Model', | |
| line: { color: '#f43f5e', width: 2.6 } | |
| }; | |
| const layout = Object.assign({}, layoutConfig, { | |
| title: { text: 'Experimental vs. Fitted Cyclic Voltammogram Overlay', font: { color: '#ffffff', size: 14 } }, | |
| xaxis: Object.assign({}, layoutConfig.xaxis, { title: 'Applied Potential <i>V</i> (V vs. Ref)' }), | |
| yaxis: Object.assign({}, layoutConfig.yaxis, { title: 'Current <i>I</i> (A)' }), | |
| showlegend: true, | |
| legend: { x: 0.02, y: 0.98, bgcolor: 'rgba(15, 23, 42, 0.8)', font: { color: '#f1f5f9' } } | |
| }); | |
| Plotly.react('live-chart', [traceExp, traceSim], layout, { responsive: true, displaylogo: false }); | |
| } | |
| function displayExtractedResults(results) { | |
| const resultsPanel = document.getElementById('results-panel'); | |
| if (resultsPanel) resultsPanel.classList.remove('hidden'); | |
| const paramsDiv = document.getElementById('params-output'); | |
| if (paramsDiv) { | |
| paramsDiv.innerHTML = ''; | |
| const params = results.params || {}; | |
| const cards = [ | |
| { label: 'Diffusivity Constant (D₀)', value: `${(params.D0 || 0).toExponential(3)} cm²/s` }, | |
| { label: 'Thermodynamic Potential (V_c)', value: `${(params.Vc || 0).toFixed(4)} V` }, | |
| { label: 'Asymmetry Factor Left (β_L)', value: `${(params.beta_L || 0).toFixed(4)} V⁻²` }, | |
| { label: 'Asymmetry Factor Right (β_R)', value: `${(params.beta_R || 0).toFixed(4)} V⁻²` }, | |
| { label: 'Baseline DC Offset (I_offset)', value: `${(params.I_offset || 0).toExponential(3)} A` }, | |
| { label: 'Objective Loss (L_final)', value: results.final_loss ? results.final_loss.toExponential(4) : 'Converged' } | |
| ]; | |
| cards.forEach(c => { | |
| const card = document.createElement('div'); | |
| card.className = 'stat-card'; | |
| card.innerHTML = ` | |
| <span class="stat-label">${c.label}</span> | |
| <span class="stat-value">${c.value}</span> | |
| `; | |
| paramsDiv.appendChild(card); | |
| }); | |
| } | |
| if (results.plots) { | |
| renderSecondaryPlots(results.plots); | |
| } | |
| } | |
| function renderSecondaryPlots(plots) { | |
| if (!window.Plotly) return; | |
| // DOS Plot | |
| const dosTraces = []; | |
| if (plots.dos_peaks && plots.dos_peaks.length > 0) { | |
| plots.dos_peaks.forEach((peak, i) => { | |
| dosTraces.push({ | |
| x: plots.v_plot, | |
| y: peak, | |
| mode: 'lines', | |
| type: 'scatter', | |
| name: `Sub-band ${i+1}`, | |
| line: { width: 1, dash: 'dot', color: 'rgba(56, 189, 248, 0.35)' }, | |
| showlegend: false | |
| }); | |
| }); | |
| } | |
| dosTraces.push({ | |
| x: plots.v_plot, | |
| y: plots.dos_total, | |
| mode: 'lines', | |
| type: 'scatter', | |
| name: 'Total DOS(V)', | |
| line: { color: '#10b981', width: 2.5 } | |
| }); | |
| const dosLayout = Object.assign({}, layoutConfig, { | |
| title: { text: 'Extracted Density of States DOS(V)', font: { color: '#ffffff', size: 14 } }, | |
| xaxis: Object.assign({}, layoutConfig.xaxis, { title: 'Potential <i>V</i> (V vs. Ref)', autorange: true }), | |
| yaxis: Object.assign({}, layoutConfig.yaxis, { title: 'DOS (a.u.)', autorange: true, tickformat: '.2e' }), | |
| showlegend: false | |
| }); | |
| Plotly.react('dos-chart', dosTraces, dosLayout, { responsive: true, displaylogo: false }); | |
| // Diffusivity D(V) Plot | |
| const traceDiff = { | |
| x: plots.v_plot, | |
| y: plots.d_of_v, | |
| mode: 'lines', | |
| type: 'scatter', | |
| name: 'D(V)', | |
| line: { color: '#38bdf8', width: 2.5 } | |
| }; | |
| const diffLayout = Object.assign({}, layoutConfig, { | |
| title: { text: 'Voltage-Dependent Diffusivity Profile D(V)', font: { color: '#ffffff', size: 14 } }, | |
| xaxis: Object.assign({}, layoutConfig.xaxis, { title: 'Potential <i>V</i> (V vs. Ref)', autorange: true }), | |
| yaxis: Object.assign({}, layoutConfig.yaxis, { title: 'Diffusivity <i>D</i> (cm²/s)', type: 'log', autorange: true, tickformat: '.1e' }), | |
| showlegend: false | |
| }); | |
| Plotly.react('diffusivity-chart', [traceDiff], diffLayout, { responsive: true, displaylogo: false }); | |
| } | |
| // Global Export Functions | |
| window.exportResultsJson = function() { | |
| if (!latestResults) return; | |
| const jsonStr = JSON.stringify(latestResults, null, 2); | |
| downloadFile(jsonStr, 'cv_extracted_parameters.json', 'application/json'); | |
| }; | |
| window.exportResultsCsv = function() { | |
| if (!latestResults || !latestResults.plots) return; | |
| const p = latestResults.plots; | |
| const rows = ["Index,Potential_V,Exp_Current_A,Sim_Current_A,V_Plot,D_of_V,DOS_Total"]; | |
| const maxLen = Math.max(p.exp_potential.length, p.v_plot.length); | |
| for (let i = 0; i < maxLen; i++) { | |
| const pot = i < p.exp_potential.length ? p.exp_potential[i] : ""; | |
| const expCur = i < p.exp_current.length ? p.exp_current[i] : ""; | |
| const simCur = i < p.sim_current.length ? p.sim_current[i] : ""; | |
| const vp = i < p.v_plot.length ? p.v_plot[i] : ""; | |
| const dv = i < p.d_of_v.length ? p.d_of_v[i] : ""; | |
| const dos = i < p.dos_total.length ? p.dos_total[i] : ""; | |
| rows.push(`${i},${pot},${expCur},${simCur},${vp},${dv},${dos}`); | |
| } | |
| downloadFile(rows.join("\n"), 'cv_extracted_curves.csv', 'text/csv'); | |
| }; | |
| function downloadFile(content, fileName, contentType) { | |
| const a = document.createElement("a"); | |
| const file = new Blob([content], { type: contentType }); | |
| a.href = URL.createObjectURL(file); | |
| a.download = fileName; | |
| document.body.appendChild(a); | |
| a.click(); | |
| setTimeout(() => { | |
| document.body.removeChild(a); | |
| window.URL.revokeObjectURL(a.href); | |
| }, 100); | |
| } | |
| // Master Initialization Function | |
| window.__initCVApp = function() { | |
| if (window.Plotly && expPotential.length > 0) { | |
| Plotly.Plots.resize('live-chart'); | |
| } | |
| }; | |
| // Window resize observer to keep Plotly charts perfectly proportioned | |
| window.addEventListener('resize', () => { | |
| if (window.Plotly) { | |
| const chartIds = ['live-chart', 'dos-chart', 'diffusivity-chart']; | |
| chartIds.forEach(id => { | |
| const el = document.getElementById(id); | |
| if (el && el.data) { | |
| Plotly.Plots.resize(id); | |
| } | |
| }); | |
| } | |
| }); | |
| // Run initialization immediately and on DOM load | |
| if (typeof document !== 'undefined') { | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', window.__initCVApp); | |
| } else { | |
| window.__initCVApp(); | |
| } | |
| } |