Interactive Scenario Simulator - with SLIDERS

The goal was to create a “what-if” analysis tool that lives right inside Grist. It lets you take any formula column, instantly turn its variables into interactive sliders, and see how the result changes in real-time. Best of all, it automatically generates a sensitivity analysis chart to visualize the impact of any single variable.

  • Automatic Interface Generation: Just select a formula column, and the widget automatically finds all the variables (e.g., $Price, $Units_Sold) and creates sliders for them.

  • Live Calculation: As you move the sliders, the main result updates instantly. The simulation is virtual and does not change your underlying Grist data.

  • Visual Formula Display: See your formula with colorful “pills” for each variable, along with a live view showing the current numbers being used in the calculation. This makes complex formulas easy to understand.

  • Interactive Sensitivity Chart:

    • Choose any variable to plot on the X-axis.

    • The chart instantly shows a curve of how the result changes across the entire range of that variable.

    • Adjust the other sliders (the “parameters”) and watch the entire curve shift in real-time to reflect the new scenario!

  • Fine-Tuned Control: Each slider is paired with a number input, so you can make large-scale adjustments by dragging or type in a precise value for fine-tuning.

  • Lockable Variables: For any variable, you can choose to “lock” it, removing its slider and fixing its value. You can lock it to the value from the selected Grist row or to a custom fixed number.

EXPLAINING THE CHART
Imagine a simple formula: Result = Variable A + Variable B

  1. Configuration:

    • You set Variable A with Min: -10, Max: 10, and Increment: 1.

    • You set Variable B with a Min/Max range as well.

  2. Analysis:

    • In the “Analyze on X-Axis” dropdown, you select Variable A.

    • The chart’s horizontal axis is immediately drawn, showing all possible points from -10 to 10.

  3. Setting the Scenario:

    • You move the slider for Variable B until it is set to 3. This value is now the “fixed parameter” for our simulation.

What the Graph Shows You:

The curve you see is a plot of all possible results if Variable B is always 3.

  • When the chart looks at the X-axis point for -10, it calculates -10 + 3 and plots a Y-value of -7.

  • When it looks at the X-axis point for 0, it calculates 0 + 3 and plots a Y-value of 3.

  • When it looks at the X-axis point for 10, it calculates 10 + 3 and plots a Y-value of 13.

If you then move the slider for Variable B to 5, the entire curve will instantly shift upwards, re-plotting the new scenario where every calculation is (Variable A) + 5.

4 Likes

Final Update

A - Compact Mode

B - Change fixed title + select column to change depending on row selected

C - Content with your simulation? Throw slider values back to the Grist table

D - shows as a red dot the exact value of the sliders in the chart

1 Like

HTML code

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <title>Scenario Simulator – Grist Widget</title>
    <script src="https://docs.getgrist.com/grist-plugin-api.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <!-- Global helper functions are now managed within the main script -->
    <style>
        :root {
            --primary-color: #0d6efd; --light-gray: #f8f9fa; --border-color: #dee2e6;
            --text-color: #212529; --label-color: #495057;
        }
        html, body { height: 100%; margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: #fff; color: var(--text-color); display: flex; flex-direction: column; font-size: 14px; }
        .widget-container { flex-grow: 1; padding: 1rem; overflow-y: auto; }
        #config-screen, #simulation-screen { display: flex; flex-direction: column; gap: 1rem; }
        .config-step { padding: 1rem; border: 1px solid var(--border-color); border-radius: 6px; background-color: var(--light-gray); }
        .config-step h2 { margin-top: 0; font-size: 1.1rem; color: var(--primary-color); border-bottom: 2px solid var(--primary-color); padding-bottom: 0.5rem; margin-bottom: 1rem; }
        label { display: block; margin-bottom: 0.3rem; font-weight: 600; font-size: 0.9em; color: var(--label-color); }
        select, input[type="number"], input[type="text"] { width: 100%; padding: 0.5rem; border: 1px solid var(--border-color); border-radius: 4px; font-size: 0.9em; box-sizing: border-box; }
        .variable-config-row { padding: 0.8rem; border: 1px solid #ccc; border-radius: 6px; background-color: #fff; }
        .variable-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.8rem; }
        .variable-header strong { font-size: 1rem; }
        .config-toggle { display: flex; align-items: center; gap: 0.3rem; font-size: 0.9em; font-weight: normal; }
        .variable-inputs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; }
        .lock-options { padding-top: 0.8rem; margin-top: 0.8rem; border-top: 1px dashed #ccc; }
        .btn { padding: 0.5rem 1rem; font-size: 0.9em; font-weight: 600; border: none; border-radius: 5px; cursor: pointer; display: flex; align-items: center; gap: 0.3rem; }
        .btn-primary { background-color: var(--primary-color); color: white; }
        .btn-secondary { background-color: #6c757d; color: white; }
        .btn-success { background-color: #198754; color: white; }
        .btn-danger { background-color: #dc3545; color: white; }
        #config-actions { display: flex; justify-content: space-between; }
        #simulation-header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.5rem; }
        #result-container { border-left: 4px solid var(--primary-color); padding: 1rem; text-align: center; }
        #result-label { font-size: 1rem; font-weight: 600; color: var(--label-color); margin: 0 0 0.3rem 0; }
        #result-value { font-size: 2rem; font-weight: 700; color: var(--text-color); }
        .slider-group { margin-bottom: 1rem; }
        .slider-label { display: inline-block; margin-bottom: 0.3rem; }
        .input-with-slider { display: flex; gap: 0.8rem; align-items: center; }
        .input-with-slider input[type="range"] { flex-grow: 1; }
        .input-with-slider input[type="number"] { width: 120px; text-align: right; }
        .formula-display-box { background-color: #f1f3f5; padding: 0.8rem; border-radius: 6px; border: 1px solid var(--border-color); font-family: 'Courier New', Courier, monospace; font-size: 1rem; line-height: 1.8; margin-bottom: 0.5rem; word-wrap: break-word; }
        .pill { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 1rem; color: white; font-weight: bold; font-family: sans-serif; font-size: 0.85em; margin: 0 0.2rem; vertical-align: middle; }
        .formula-operator { color: #6c757d; font-weight: bold; margin: 0 0.2rem; }
        .collapsible-header { font-weight: bold; cursor: pointer; user-select: none; padding: 0.3rem 0; font-size: 0.9em; color: var(--label-color); }
        .collapsible-header .arrow { display: inline-block; transition: transform 0.2s; }
        .collapsible-content { padding-top: 0.5rem; }
        .collapsible-content.collapsed { display: none; }
        .collapsible-header.collapsed .arrow { transform: rotate(-90deg); }
        #chart-container { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border-color); }
        #chart-controls { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
        #chart-wrapper { position: relative; height: 300px; width: 100%; }
        #error-msg { color: #dc3545; font-weight: bold; text-align: center; padding: 1rem; border: 1px solid #f5c2c7; background-color: #f8d7da; border-radius: 6px; margin: 1rem 0; }
        .hidden { display: none !important; }

        /* -- COMPACT MODE STYLES -- */
        body.compact #simulation-screen { gap: 0.5rem; }
        body.compact .formula-display-box { padding: 0.2rem 0.5rem; font-size: 0.9em; line-height: 1.6; margin-bottom: 0.2rem; white-space: nowrap; overflow-x: auto; overflow-y: hidden; }
        body.compact .formula-operator { margin: 0 0.1rem; }
        body.compact .pill { margin: 0 0.1rem; padding: 0.1rem 0.4rem; }
        body.compact #result-container { display: flex; align-items: baseline; gap: 0.5rem; padding: 0.5rem; text-align: left; }
        body.compact #result-label { margin: 0; }
        body.compact #result-value { font-size: 1.2rem; }
        body.compact .slider-group { margin-bottom: 0.2rem; }
    </style>
</head>
<body>
    <div id="error-msg" class="hidden"></div>

    <!-- Configuration Screen -->
    <div id="config-screen" class="widget-container hidden">
        <div class="config-step">
            <h2>1. Core Setup</h2>
            <label for="formula-column-select">Formula Column to Simulate:</label>
            <select id="formula-column-select"></select>
        </div>
        <div class="config-step">
            <h2>2. Display Options</h2>
            <label for="widget-title-input">Widget Title:</label>
            <input type="text" id="widget-title-input" placeholder="e.g. Profitability Analysis">
            <label for="title-column-select" style="margin-top: 0.8rem;">Title Suffix Column (Optional):</label>
            <select id="title-column-select"></select>
            <label class="config-toggle" style="margin-top: 0.8rem;">
                <input type="checkbox" id="compact-mode-toggle"> Enable Compact Mode
            </label>
            <label class="config-toggle" style="margin-top: 0.5rem;">
                <input type="checkbox" id="show-chart-toggle"> Show Sensitivity Analysis Chart
            </label>
        </div>
        <div id="variables-step" class="config-step hidden">
            <h2>3. Configure Variables</h2>
            <div id="variable-config-container"></div>
        </div>
        <div id="config-actions">
            <button id="reset-config-btn" class="btn btn-danger">Start Over</button>
            <button id="save-config-btn" class="btn btn-primary" disabled>Save & Start Simulation</button>
        </div>
    </div>

    <!-- Simulation Screen -->
    <div id="simulation-screen" class="widget-container hidden">
        <div id="simulation-header">
            <h1 id="simulation-title" style="margin: 0; font-size: 1.3rem;">Scenario Simulator</h1>
            <div>
                <button id="save-to-grist-btn" class="btn btn-success" title="Save current slider values to the selected Grist record">
                    <!-- SVG icon for save -->
                    <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M2 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H9.5a1 1 0 0 0-1 1v4.5h2a.5.5 0 0 1 .354.854l-2.5 2.5a.5.5 0 0 1-.708 0l-2.5-2.5A.5.5 0 0 1 5.5 6.5h2V2a2 2 0 0 1 2-2H14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h2.5a.5.5 0 0 1 0 1H2z"/></svg>
                    Save to Grist
                </button>
                <button id="reconfig-btn" class="btn btn-secondary" style="margin-left: 0.5rem;">Reconfigure</button>
            </div>
        </div>
        
        <div id="formulas-container">
            <div class="collapsible-header" data-target="formula-display-content">
                <span class="arrow">▼</span> Formula
            </div>
            <div id="formula-display-content" class="collapsible-content">
                <div class="formula-display-box" id="formula-display-container"></div>
                <div class="formula-display-box" id="live-values-container"></div>
            </div>
        </div>

        <div id="result-container">
            <h2 id="result-label">Result</h2>
            <div id="result-value">-- select a record --</div>
        </div>
        <div id="sliders-container"></div>
        <div id="chart-container" class="hidden">
            <div class="collapsible-header" data-target="chart-content">
                <span class="arrow">▼</span> Analysis Chart
            </div>
            <div id="chart-content" class="collapsible-content">
                <div id="chart-controls">
                    <label for="xaxis-select">Analyze on X-Axis:</label>
                    <select id="xaxis-select"></select>
                </div>
                <div id="chart-wrapper">
                    <canvas id="sensitivity-chart"></canvas>
                </div>
            </div>
        </div>
    </div>
</body>
</html>

JS Code

document.addEventListener('DOMContentLoaded', function () {
    const WIDGET_CONFIG_KEY = 'scenarioSimulatorWidgetConfig_v14_scope_fix';
    const PALETTE = ["#1E88E5", "#43A047", "#FB8C00", "#E53535", "#8E24AA", "#00838F", "#D81B60"];
    const HIGHLIGHT_COLOR = 'rgba(220, 53, 69, 1)', HIGHLIGHT_RADIUS = 7, DEFAULT_RADIUS = 3;

    // --- GLOBAL HELPER FUNCTIONS ---
    // Make these functions globally available for inline event handlers in a structured way
    window.globalWidgetFunctions = {
        handleLockChange: (checkbox) => {
            const optionsDiv = checkbox.closest('.variable-config-row').querySelector('.lock-options');
            optionsDiv.classList.toggle('hidden', !checkbox.checked);
        },
        updateSliderAndInput: (sourceElement) => {
            const varId = sourceElement.dataset.variableId;
            const slider = document.getElementById(`slider-${varId}`);
            const numberInput = document.getElementById(`number-${varId}`);
            if (sourceElement.type === 'range') {
                numberInput.value = sourceElement.value;
            } else {
                slider.value = sourceElement.value;
            }
            // The main recalculate function is still safely scoped
            window.handleSliderChange();
        }
    };

    // --- DATA & CONFIG MODULES ---
    const GristDataManager = (() => { /* ... (stable code, no changes) ... */ const state = { tables: null, columns: null }; let _tableMeta = null; async function _loadMeta() { if (!state.tables) state.tables = await grist.docApi.fetchTable('_grist_Tables'); if (!state.columns) state.columns = await grist.docApi.fetchTable('_grist_Tables_column'); } function _getNumericTableId(tableId) { const idx = state.tables.tableId.findIndex(t => t === tableId); return idx !== -1 ? state.tables.id[idx] : null; } function _getColumnsForTable(numericId) { const cols = []; const allCols = state.columns; const parentIdKey = allCols.parentId ? 'parentId' : 'tableId'; for (let i = 0; i < allCols.id.length; i++) { if (allCols[parentIdKey][i] === numericId) { cols.push({id: allCols.colId[i], label: allCols.label[i], type: allCols.type[i], isFormula: !!allCols.formula[i], formula: allCols.formula[i] || ''}); } } return cols; } async function fetchAll() { try { const tableId = await grist.selectedTable.getTableId(); if (!tableId) throw new Error("No table is linked to this widget."); await _loadMeta(); const numericId = _getNumericTableId(tableId); if (!numericId) throw new Error(`Could not find metadata for table: ${tableId}`); _tableMeta = { id: tableId, columns: _getColumnsForTable(numericId) }; return { tableMeta: _tableMeta }; } catch (e) { console.error("GDM Error:", e); _tableMeta = null; throw e; } } return { fetchAll, getTableMeta: () => _tableMeta }; })();
    const WidgetConfigManager = (() => { const DEFAULTS = { formulaColumnId: null, variableSettings: {}, showChart: true, compactMode: false, widgetTitle: "Scenario Simulator", titleColumnId: null }; let _currentConfig = { ...DEFAULTS }; async function loadConfig() { const stored = await grist.getOption(WIDGET_CONFIG_KEY); _currentConfig = { ...DEFAULTS, ...(stored || {}) }; return _currentConfig; } async function saveConfig(config) { _currentConfig = { ..._currentConfig, ...config }; await grist.setOption(WIDGET_CONFIG_KEY, _currentConfig); } async function clearConfig() { _currentConfig = { ...DEFAULTS }; await grist.setOption(WIDGET_CONFIG_KEY, null); } return { loadConfig, saveConfig, getConfig: () => _currentConfig, clearConfig }; })();

    // --- DOM ELEMENTS & APP STATE ---
    const dom = {
        body: document.body, errorMsg: document.getElementById('error-msg'), configScreen: document.getElementById('config-screen'),
        simulationScreen: document.getElementById('simulation-screen'), formulaSelect: document.getElementById('formula-column-select'),
        variablesStep: document.getElementById('variables-step'), variableConfigContainer: document.getElementById('variable-config-container'),
        saveConfigBtn: document.getElementById('save-config-btn'), resetConfigBtn: document.getElementById('reset-config-btn'),
        reconfigBtn: document.getElementById('reconfig-btn'), resultLabel: document.getElementById('result-label'),
        resultValue: document.getElementById('result-value'), slidersContainer: document.getElementById('sliders-container'),
        formulaDisplay: document.getElementById('formula-display-container'), liveValues: document.getElementById('live-values-container'),
        chartContainer: document.getElementById('chart-container'), chartCanvas: document.getElementById('sensitivity-chart'),
        xAxisSelect: document.getElementById('xaxis-select'), showChartToggle: document.getElementById('show-chart-toggle'),
        compactModeToggle: document.getElementById('compact-mode-toggle'), widgetTitleInput: document.getElementById('widget-title-input'),
        titleColumnSelect: document.getElementById('title-column-select'), simulationTitle: document.getElementById('simulation-title'),
        saveToGristBtn: document.getElementById('save-to-grist-btn')
    };
    let state = { formula: null, evaluator: null, varIds: [], varMeta: {}, record: null, chart: null };

    // --- CORE LOGIC & UI RENDERING ---
    const tokenizeFormula = (formula) => { const regex = /\$[\w_]+|-?\d*\.?\d+|\*\*|[()*/+-]/g; return formula.match(regex) || []; };
    const parseFormulaForVariables = (formula) => { const matches = formula.match(/\$(\w+)/g); return matches ? [...new Set(matches.map(v => v.substring(1)))] : []; };
    const buildFormulaEvaluator = (formula, varIds) => { if (!formula || varIds.length === 0) return null; let body = formula; varIds.forEach(id => { body = body.replace(new RegExp(`\\$${id}`, 'g'), id); }); try { return new Function(...varIds, `return ${body};`); } catch (e) { showError(`The formula "${formula}" appears to be invalid.`); return null; } };
    const setupChart = () => { if (state.chart) state.chart.destroy(); const ctx = dom.chartCanvas.getContext('2d'); state.chart = new Chart(ctx, { type: 'line', data: { labels: [], datasets: [{ label: 'Result', data: [], borderColor: 'rgba(13, 110, 253, 0.8)', backgroundColor: 'rgba(13, 110, 253, 0.1)', fill: true, tension: 0.1, pointRadius: DEFAULT_RADIUS }] }, options: { responsive: true, maintainAspectRatio: false, interaction: { intersect: false, mode: 'index' } } }); };
    const updateChart = () => { if (!state.chart || !WidgetConfigManager.getConfig().showChart) { dom.chartContainer.classList.add('hidden'); return; } const xAxisVarId = dom.xAxisSelect.value; if (!xAxisVarId) { dom.chartContainer.classList.add('hidden'); return; } const config = WidgetConfigManager.getConfig(); const xAxisSettings = config.variableSettings[xAxisVarId]; if (!xAxisSettings || typeof xAxisSettings.min === 'undefined') return; const labels = [], dataPoints = []; const { min, max, step: userStep } = xAxisSettings; if (userStep <= 0) { dom.chartContainer.classList.add('hidden'); return; } const MAX_POINTS_TO_PLOT = 101; const totalPossiblePoints = Math.floor((max - min) / userStep) + 1; let effectiveStep = userStep; if (totalPossiblePoints > MAX_POINTS_TO_PLOT) { const samplingFactor = Math.ceil(totalPossiblePoints / MAX_POINTS_TO_PLOT); effectiveStep = userStep * samplingFactor; } for (let currentX = min; currentX <= max; currentX += effectiveStep) { labels.push(currentX); const argValues = state.varIds.map(varId => { if (varId === xAxisVarId) return currentX; const settings = config.variableSettings[varId]; if (settings.isLocked) { return settings.lockMode === 'table' && state.record ? state.record[varId] : settings.lockValue; } else { return parseFloat(document.getElementById(`number-${varId}`).value); } }); try { dataPoints.push(state.evaluator(...argValues)); } catch { dataPoints.push(null); } } if (labels.length > 0 && labels[labels.length - 1] < max) { labels.push(max); const argValues = state.varIds.map(varId => { if (varId === xAxisVarId) return max; const settings = config.variableSettings[varId]; if (settings.isLocked) { return settings.lockMode === 'table' && state.record ? state.record[varId] : settings.lockValue; } else { return parseFloat(document.getElementById(`number-${varId}`).value); } }); try { dataPoints.push(state.evaluator(...argValues)); } catch { dataPoints.push(null); } } const currentXValue = parseFloat(document.getElementById(`number-${xAxisVarId}`).value); let closestPointIndex = -1; if (labels.length > 0) { closestPointIndex = labels.reduce((closestIdx, currentVal, currentIdx) => { const closestDistance = Math.abs(labels[closestIdx] - currentXValue); const currentDistance = Math.abs(currentVal - currentXValue); return currentDistance < closestDistance ? currentIdx : closestIdx; }, 0); } const pointRadii = new Array(labels.length).fill(DEFAULT_RADIUS); const pointBackgroundColors = new Array(labels.length).fill(state.chart.data.datasets[0].borderColor); if (closestPointIndex !== -1) { pointRadii[closestPointIndex] = HIGHLIGHT_RADIUS; pointBackgroundColors[closestPointIndex] = HIGHLIGHT_COLOR; } state.chart.data.labels = labels.map(l => l.toLocaleString()); state.chart.data.datasets[0].data = dataPoints; state.chart.data.datasets[0].pointRadius = pointRadii; state.chart.data.datasets[0].pointBackgroundColor = pointBackgroundColors; const resultLabel = dom.resultLabel.textContent.split('(')[1]?.split(')')[0] || 'Result'; state.chart.data.datasets[0].label = `${resultLabel} vs ${state.varMeta[xAxisVarId].label}`; state.chart.update(); dom.chartContainer.classList.remove('hidden'); };
    const recalculateAndDisplay = () => { if (!state.evaluator) return; const config = WidgetConfigManager.getConfig(); const argValues = state.varIds.map(varId => { const settings = config.variableSettings[varId]; if (settings.isLocked) { return settings.lockMode === 'table' && state.record ? state.record[varId] : settings.lockValue; } else { return parseFloat(document.getElementById(`number-${varId}`).value); } }); try { const result = state.evaluator(...argValues); dom.resultValue.textContent = typeof result === 'number' ? result.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : String(result); } catch (e) { dom.resultValue.textContent = "Calculation Error"; } updateLiveValuesDisplay(); updateChart(); };
    window.handleSliderChange = recalculateAndDisplay;
    const showScreen = (screen) => { dom.configScreen.classList.add('hidden'); dom.simulationScreen.classList.add('hidden'); if (screen === 'config') dom.configScreen.classList.remove('hidden'); else if (screen === 'simulation') dom.simulationScreen.classList.remove('hidden'); };
    const showError = (message) => { dom.errorMsg.textContent = message; dom.errorMsg.classList.remove('hidden'); dom.configScreen.classList.add('hidden'); dom.simulationScreen.classList.add('hidden'); };
    const renderFormulaPills = () => { dom.formulaDisplay.innerHTML = ''; dom.liveValues.innerHTML = ''; const parts = tokenizeFormula(state.formula); const varCounters = {}; parts.forEach(part => { let pill, op; if (part.startsWith('$')) { const varId = part.substring(1); const variable = state.varMeta[varId]; if (varCounters[varId] === undefined) { varCounters[varId] = 0; } if (variable) { pill = document.createElement('span'); pill.className = 'pill'; pill.textContent = variable.label; pill.style.backgroundColor = variable.color; dom.formulaDisplay.appendChild(pill); pill = document.createElement('span'); pill.className = 'pill'; pill.id = `live-pill-${varId}-${varCounters[varId]}`; pill.textContent = '...'; pill.style.backgroundColor = variable.color; dom.liveValues.appendChild(pill); varCounters[varId]++; } } else { op = document.createElement('span'); op.className = 'formula-operator'; op.textContent = ` ${part} `; dom.formulaDisplay.appendChild(op); op = document.createElement('span'); op.className = 'formula-operator'; op.textContent = ` ${part} `; dom.liveValues.appendChild(op); } }); };
    const updateLiveValuesDisplay = () => { const config = WidgetConfigManager.getConfig(); state.varIds.forEach(varId => { let value; const settings = config.variableSettings[varId]; if (settings.isLocked) { value = settings.lockMode === 'table' && state.record ? state.record[varId] : settings.lockValue; } else { const numInput = document.getElementById(`number-${varId}`); value = numInput ? parseFloat(numInput.value) : 0; } const allPillsForVar = dom.liveValues.querySelectorAll(`[id^="live-pill-${varId}-"]`); allPillsForVar.forEach(pill => { pill.textContent = (value ?? 0).toLocaleString(); }); }); };
    
    // --- THIS IS THE CORRECTED FUNCTION ---
    const buildVariableConfigUI = (variableIds, existingSettings = {}) => {
        dom.variableConfigContainer.innerHTML = ''; const tableMeta = GristDataManager.getTableMeta(); if (!variableIds.length) { dom.variablesStep.classList.add('hidden'); dom.saveConfigBtn.disabled = true; return; }
        variableIds.forEach(varId => {
            const column = tableMeta.columns.find(c => c.id === varId); if (!column) return;
            const settings = existingSettings[varId] || {};
            const row = document.createElement('div'); row.className = 'variable-config-row'; row.dataset.variableId = varId;
            row.innerHTML = `
                <div class="variable-header"><strong>Variable: ${column.label}</strong><label class="config-toggle"><input type="checkbox" class="lock-checkbox" onchange="window.globalWidgetFunctions.handleLockChange(this)" ${settings.isLocked ? 'checked' : ''}> Lock</label></div>
                <div class="variable-inputs">
                    <div><label for="min-${varId}">Min</label><input type="number" id="min-${varId}" value="${settings.min ?? ''}" placeholder="e.g. 0"></div>
                    <div><label for="max-${varId}">Max</label><input type="number" id="max-${varId}" value="${settings.max ?? ''}" placeholder="e.g. 1000"></div>
                    <div><label for="step-${varId}">Step</label><input type="number" id="step-${varId}" value="${settings.step ?? ''}" placeholder="e.g. 10"></div>
                </div>
                <div class="lock-options ${settings.isLocked ? '' : 'hidden'}">
                    <label><input type="radio" name="lock-mode-${varId}" value="table" ${settings.lockMode === 'custom' ? '' : 'checked'}> Use value from table</label>
                    <label style="display: flex; align-items: center; gap: 0.5rem; margin-top: 0.5rem;"><input type="radio" name="lock-mode-${varId}" value="custom" ${settings.lockMode === 'custom' ? 'checked' : ''}> Use fixed value:<input type="number" class="lock-value-input" value="${settings.lockValue ?? 0}" style="flex-grow:1;"></label>
                </div>`;
            dom.variableConfigContainer.appendChild(row);
        });
        dom.variablesStep.classList.remove('hidden'); dom.saveConfigBtn.disabled = false;
    };
    
    const initializeConfigScreen = (prefill = false) => { const tableMeta = GristDataManager.getTableMeta(); const config = WidgetConfigManager.getConfig(); const formulaColumns = tableMeta.columns.filter(c => c.isFormula); dom.formulaSelect.innerHTML = '<option value="">-- Please select a formula --</option>'; formulaColumns.forEach(col => dom.formulaSelect.add(new Option(`${col.label} (${col.id})`, col.id))); dom.titleColumnSelect.innerHTML = '<option value="">-- None (use Record ID) --</option>'; tableMeta.columns.forEach(col => { if (!col.isFormula) dom.titleColumnSelect.add(new Option(col.label, col.id)); }); dom.widgetTitleInput.value = config.widgetTitle; dom.titleColumnSelect.value = config.titleColumnId || ''; dom.showChartToggle.checked = config.showChart; dom.compactModeToggle.checked = config.compactMode; if (prefill && config.formulaColumnId) { dom.formulaSelect.value = config.formulaColumnId; const formulaColumn = tableMeta.columns.find(c => c.id === config.formulaColumnId); buildVariableConfigUI(parseFormulaForVariables(formulaColumn.formula), config.variableSettings); } else { dom.variablesStep.classList.add('hidden'); dom.saveConfigBtn.disabled = true; } showScreen('config'); };
    
    // --- THIS IS THE CORRECTED FUNCTION ---
    const initializeSimulationScreen = () => {
        const config = WidgetConfigManager.getConfig(); const tableMeta = GristDataManager.getTableMeta(); const formulaColumn = tableMeta.columns.find(c => c.id === config.formulaColumnId);
        dom.body.classList.toggle('compact', config.compactMode);
        if (config.compactMode) { const formulaLabel = formulaColumn.label.replace(/_/g, ' '); dom.resultLabel.textContent = `${formulaLabel} =`; } else { dom.resultLabel.textContent = `Result (${formulaColumn.label})`; }
        state.varIds = Object.keys(config.variableSettings); state.formula = formulaColumn.formula; state.varMeta = {}; state.varIds.forEach((varId, index) => { const column = tableMeta.columns.find(c => c.id === varId); state.varMeta[varId] = { id: varId, label: column.label, color: PALETTE[index % PALETTE.length] }; }); state.evaluator = buildFormulaEvaluator(state.formula, state.varIds); if (!state.evaluator) { WidgetConfigManager.clearConfig(); main(); return; } renderFormulaPills(); dom.resultValue.textContent = "-- select a record --"; dom.slidersContainer.innerHTML = ''; dom.xAxisSelect.innerHTML = '';
        state.varIds.forEach(varId => {
            const settings = config.variableSettings[varId]; if (settings.isLocked) return;
            dom.xAxisSelect.add(new Option(state.varMeta[varId].label, varId));
            const group = document.createElement('div'); group.className = 'slider-group';
            group.innerHTML = `
                <label for="slider-${varId}" class="slider-label"><span class="pill" style="background-color: ${state.varMeta[varId].color};">${state.varMeta[varId].label}</span></label>
                <div class="input-with-slider">
                    <input type="range" id="slider-${varId}" data-variable-id="${varId}" min="${settings.min}" max="${settings.max}" step="${settings.step}" oninput="window.globalWidgetFunctions.updateSliderAndInput(this)">
                    <input type="number" id="number-${varId}" data-variable-id="${varId}" min="${settings.min}" max="${settings.max}" step="${settings.step}" oninput="window.globalWidgetFunctions.updateSliderAndInput(this)">
                </div>`;
            dom.slidersContainer.appendChild(group);
        });
        setupChart(); showScreen('simulation');
    };

    // --- EVENT HANDLERS ---
    dom.formulaSelect.addEventListener('change', () => { const selectedColumnId = dom.formulaSelect.value; if (!selectedColumnId) { dom.variablesStep.classList.add('hidden'); dom.saveConfigBtn.disabled = true; return; } const formulaColumn = GristDataManager.getTableMeta().columns.find(c => c.id === selectedColumnId); buildVariableConfigUI(parseFormulaForVariables(formulaColumn.formula)); });
    dom.saveConfigBtn.addEventListener('click', async () => { const formulaColumnId = dom.formulaSelect.value; const variableSettings = {}; let isValid = true; dom.variableConfigContainer.querySelectorAll('.variable-config-row').forEach(row => { const varId = row.dataset.variableId; const isLocked = row.querySelector('.lock-checkbox').checked; const min = row.querySelector(`#min-${varId}`).value, max = row.querySelector(`#max-${varId}`).value, step = row.querySelector(`#step-${varId}`).value; if (!isLocked && (min === '' || max === '' || step === '')) isValid = false; variableSettings[varId] = { isLocked: isLocked, min: parseFloat(min), max: parseFloat(max), step: parseFloat(step), lockMode: row.querySelector('input[name="lock-mode-'+varId+'"]:checked').value, lockValue: parseFloat(row.querySelector('.lock-value-input').value) }; }); if (!isValid) { alert("Please fill in Min, Max, and Step for all unlocked variables."); return; } const showChart = dom.showChartToggle.checked; const compactMode = dom.compactModeToggle.checked; const widgetTitle = dom.widgetTitleInput.value; const titleColumnId = dom.titleColumnSelect.value; await WidgetConfigManager.saveConfig({ formulaColumnId, variableSettings, showChart, compactMode, widgetTitle, titleColumnId }); initializeSimulationScreen(); if(state.record) onRecordHandler(state.record); });
    dom.reconfigBtn.addEventListener('click', () => initializeConfigScreen(true));
    dom.resetConfigBtn.addEventListener('click', async () => { if (confirm("Are you sure you want to start over? All settings will be lost.")) { await WidgetConfigManager.clearConfig(); initializeConfigScreen(false); } });
    dom.xAxisSelect.addEventListener('change', updateChart);
    document.querySelectorAll('.collapsible-header').forEach(header => { header.addEventListener('click', () => { const content = document.getElementById(header.dataset.target); content.classList.toggle('collapsed'); header.classList.toggle('collapsed'); }); });
    dom.saveToGristBtn.addEventListener('click', async () => { if (!state.record) { alert("Please select a record in the Grist table first."); return; } const config = WidgetConfigManager.getConfig(); const table = grist.getTable(GristDataManager.getTableMeta().id); const valuesToUpdate = {}; let updatedCount = 0; state.varIds.forEach(varId => { const settings = config.variableSettings[varId]; if (!settings.isLocked) { const currentValue = parseFloat(document.getElementById(`number-${varId}`).value); if (state.record[varId] !== currentValue) { valuesToUpdate[varId] = currentValue; updatedCount++; } } }); if (updatedCount > 0) { try { await table.update({id: state.record.id, fields: valuesToUpdate}); dom.saveToGristBtn.innerHTML = `✓ Saved!`; setTimeout(() => dom.saveToGristBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M2 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H9.5a1 1 0 0 0-1 1v4.5h2a.5.5 0 0 1 .354.854l-2.5 2.5a.5.5 0 0 1-.708 0l-2.5-2.5A.5.5 0 0 1 5.5 6.5h2V2a2 2 0 0 1 2-2H14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h2.5a.5.5 0 0 1 0 1H2z"/></svg> Save to Grist`, 2000); } catch (err) { alert(`Error saving to Grist: ${err.message}`); } } else { alert("No changes to save. Slider values already match the Grist record."); } });

    // --- GRIST LISTENERS & INITIALIZATION ---
    async function main() { dom.errorMsg.classList.add('hidden'); try { await GristDataManager.fetchAll(); const config = await WidgetConfigManager.loadConfig(); if (config.formulaColumnId) { initializeSimulationScreen(); } else { initializeConfigScreen(); } } catch (e) { showError(e.message); } }
    
    const onRecordHandler = (record) => {
        state.record = record;
        const config = WidgetConfigManager.getConfig();
        const titleSuffix = config.titleColumnId && record ? record[config.titleColumnId] : (record ? `ID: ${record.id}`: '');
        dom.simulationTitle.textContent = `${config.widgetTitle} - ${titleSuffix}`;
        if (!record || dom.simulationScreen.classList.contains('hidden')) {
            if(!dom.simulationScreen.classList.contains('hidden')) dom.resultValue.textContent = "-- select a record --";
            dom.saveToGristBtn.disabled = true;
            return;
        }
        dom.saveToGristBtn.disabled = false;
        state.varIds.forEach(varId => {
            const settings = config.variableSettings[varId]; if (settings.isLocked) return;
            const slider = document.getElementById(`slider-${varId}`); const numberInput = document.getElementById(`number-${varId}`);
            if (slider && numberInput) {
                const recordValue = record[varId] ?? 0;
                const value = Math.max(settings.min, Math.min(settings.max, recordValue));
                slider.value = value; numberInput.value = value;
            }
        });
        recalculateAndDisplay();
    };

    grist.ready({ requiredAccess: 'full' });
    grist.onRecords(main);
    grist.onRecord(onRecordHandler);
});

I’ve been doing some debugging because on my tests there was one variable that didn´t change when I selected a different row of the table. Turns out the problem was NOT the code… but some caching issue… even reloading the page with console open on NEtwork Tab with Disable Cache On, it was not solving.

So I changed the code here and there to try to fix and it wasn´t working.

When I connected the widget to ANOTHER table, all columns changed when I changed the record.

Then I connected back to the table where one column seemed to not be mapped and… it was WORKING! Go figure…

Long story short, I lost track (since I am changing the code directly on the tabs) of what version I posted above and which one I am using.

So I will post the last version, which seems to have everything working.

HTML

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <title>Scenario Simulator – Grist Widget</title>
    <script src="https://docs.getgrist.com/grist-plugin-api.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <style>
        :root {
            --primary-color: #0d6efd; --light-gray: #f8f9fa; --border-color: #dee2e6;
            --text-color: #212529; --label-color: #495057;
        }
        html, body { height: 100%; margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: #fff; color: var(--text-color); display: flex; flex-direction: column; font-size: 14px; }
        .widget-container { flex-grow: 1; padding: 1rem; overflow-y: auto; }
        #config-screen, #simulation-screen { display: flex; flex-direction: column; gap: 1rem; }
        .config-step { padding: 1rem; border: 1px solid var(--border-color); border-radius: 6px; background-color: var(--light-gray); }
        .config-step h2 { margin-top: 0; font-size: 1.1rem; color: var(--primary-color); border-bottom: 2px solid var(--primary-color); padding-bottom: 0.5rem; margin-bottom: 1rem; }
        label { display: block; margin-bottom: 0.3rem; font-weight: 600; font-size: 0.9em; color: var(--label-color); }
        select, input[type="number"], input[type="text"] { width: 100%; padding: 0.5rem; border: 1px solid var(--border-color); border-radius: 4px; font-size: 0.9em; box-sizing: border-box; }
        .variable-config-row { padding: 0.8rem; border: 1px solid #ccc; border-radius: 6px; background-color: #fff; }
        .variable-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.8rem; }
        .variable-header strong { font-size: 1rem; }
        .config-toggle { display: flex; align-items: center; gap: 0.3rem; font-size: 0.9em; font-weight: normal; }
        .variable-inputs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; }
        .lock-options { padding-top: 0.8rem; margin-top: 0.8rem; border-top: 1px dashed #ccc; }
        .btn { padding: 0.5rem 1rem; font-size: 0.9em; font-weight: 600; border: none; border-radius: 5px; cursor: pointer; display: flex; align-items: center; gap: 0.3rem; }
        .btn-primary { background-color: var(--primary-color); color: white; }
        .btn-secondary { background-color: #6c757d; color: white; }
        .btn-success { background-color: #198754; color: white; }
        .btn-danger { background-color: #dc3545; color: white; }
        #config-actions { display: flex; justify-content: space-between; }
        #simulation-header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.5rem; }
        #result-container { border-left: 4px solid var(--primary-color); padding: 1rem; text-align: center; }
        #result-label { font-size: 1rem; font-weight: 600; color: var(--label-color); margin: 0 0 0.3rem 0; }
        #result-value { font-size: 2rem; font-weight: 700; color: var(--text-color); }
        .slider-group { margin-bottom: 1rem; }
        .slider-label { display: inline-block; margin-bottom: 0.3rem; }
        .input-with-slider { display: flex; gap: 0.8rem; align-items: center; }
        .input-with-slider input[type="range"] { flex-grow: 1; }
        .input-with-slider input[type="number"] { width: 120px; text-align: right; }
        .formula-display-box { background-color: #f1f3f5; padding: 0.8rem; border-radius: 6px; border: 1px solid var(--border-color); font-family: 'Courier New', Courier, monospace; font-size: 1rem; line-height: 1.8; margin-bottom: 0.5rem; word-wrap: break-word; }
        .pill { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 1rem; color: white; font-weight: bold; font-family: sans-serif; font-size: 0.85em; margin: 0 0.2rem; vertical-align: middle; }
        .formula-operator { color: #6c757d; font-weight: bold; margin: 0 0.2rem; }
        .collapsible-header { font-weight: bold; cursor: pointer; user-select: none; padding: 0.3rem 0; font-size: 0.9em; color: var(--label-color); }
        .collapsible-header .arrow { display: inline-block; transition: transform 0.2s; }
        .collapsible-content { padding-top: 0.5rem; }
        .collapsible-content.collapsed { display: none; }
        .collapsible-header.collapsed .arrow { transform: rotate(-90deg); }
        #chart-container { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border-color); }
        #chart-controls { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
        #chart-wrapper { position: relative; height: 300px; width: 100%; }
        #error-msg { color: #dc3545; font-weight: bold; text-align: center; padding: 1rem; border: 1px solid #f5c2c7; background-color: #f8d7da; border-radius: 6px; margin: 1rem 0; }
        .hidden { display: none !important; }

        /* --- DEBUG STYLES --- */
        #debug-container { margin-top: 1rem; }
        #debug-log { background-color: #212529; color: #0f0; font-family: monospace; font-size: 12px; height: 200px; overflow-y: auto; padding: 0.5rem; border-radius: 4px; white-space: pre-wrap; }
    </style>
</head>
<body>
    <div id="error-msg" class="hidden"></div>

    <div id="config-screen" class="widget-container hidden">
        <div class="config-step">
            <h2>1. Core Setup</h2>
            <label for="formula-column-select">Formula Column to Simulate:</label>
            <select id="formula-column-select"></select>
        </div>
        <div class="config-step">
            <h2>2. Display Options</h2>
            <label for="widget-title-input">Widget Title:</label>
            <input type="text" id="widget-title-input" placeholder="e.g. Profitability Analysis">
            <label for="title-column-select" style="margin-top: 0.8rem;">Title Suffix Column (Optional):</label>
            <select id="title-column-select"></select>
            <label class="config-toggle" style="margin-top: 0.8rem;">
                <input type="checkbox" id="compact-mode-toggle"> Enable Compact Mode
            </label>
            <label class="config-toggle" style="margin-top: 0.5rem;">
                <input type="checkbox" id="show-chart-toggle"> Show Sensitivity Analysis Chart
            </label>
            <!-- NEW DEBUG TOGGLE -->
            <label class="config-toggle" style="margin-top: 0.5rem; color: #dc3545;">
                <input type="checkbox" id="debug-mode-toggle"> Enable Debug Mode
            </label>
        </div>
        <div id="variables-step" class="config-step hidden">
            <h2>3. Configure Variables</h2>
            <div id="variable-config-container"></div>
        </div>
        <div id="config-actions">
            <button id="reset-config-btn" class="btn btn-danger">Start Over</button>
            <button id="save-config-btn" class="btn btn-primary" disabled>Save & Start Simulation</button>
        </div>
    </div>

    <div id="simulation-screen" class="widget-container hidden">
        <div id="simulation-header">
            <h1 id="simulation-title" style="margin: 0; font-size: 1.3rem;">Scenario Simulator</h1>
            <div>
                <button id="save-to-grist-btn" class="btn btn-success" title="Save current slider values to the selected Grist record">
                    <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M2 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H9.5a1 1 0 0 0-1 1v4.5h2a.5.5 0 0 1 .354.854l-2.5 2.5a.5.5 0 0 1-.708 0l-2.5-2.5A.5.5 0 0 1 5.5 6.5h2V2a2 2 0 0 1 2-2H14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h2.5a.5.5 0 0 1 0 1H2z"/></svg>
                    Save to Grist
                </button>
                <button id="reconfig-btn" class="btn btn-secondary" style="margin-left: 0.5rem;">Reconfigure</button>
            </div>
        </div>
        
        <div id="formulas-container">
            <!-- Collapsible sections will be managed by JS -->
        </div>

        <div id="result-container">
            <h2 id="result-label">Result</h2>
            <div id="result-value">-- select a record --</div>
        </div>
        <div id="sliders-container"></div>
        <div id="chart-container" class="hidden">
            <!-- Chart content will be managed by JS -->
        </div>

        <!-- NEW DEBUG CONTAINER -->
        <div id="debug-container" class="hidden">
            <div style="display:flex; justify-content: space-between; align-items: center;">
                <h3 style="margin:0;">Debug Log</h3>
                <button id="clear-log-btn" class="btn btn-secondary">Clear Log</button>
            </div>
            <div id="debug-log"></div>
        </div>
    </div>
</body>
</html>

JS

document.addEventListener('DOMContentLoaded', function () {
    const WIDGET_CONFIG_KEY = 'scenarioSimulatorWidgetConfig_v23_init_fix';
    const PALETTE = ["#1E88E5", "#43A047", "#FB8C00", "#E53535", "#8E24AA", "#00838F", "#D81B60"];
    const HIGHLIGHT_COLOR = 'rgba(220, 53, 69, 1)', HIGHLIGHT_RADIUS = 7, DEFAULT_RADIUS = 3;

    // --- GLOBAL HELPER FUNCTIONS ---
    window.globalWidgetFunctions = {
        handleLockChange: (checkbox) => { const optionsDiv = checkbox.closest('.variable-config-row').querySelector('.lock-options'); optionsDiv.classList.toggle('hidden', !checkbox.checked); },
        updateSliderAndInput: (sourceElement) => { const varId = sourceElement.dataset.variableId; const slider = document.getElementById(`slider-${varId}`); const numberInput = document.getElementById(`number-${varId}`); if (sourceElement.type === 'range') { numberInput.value = sourceElement.value; } else { slider.value = sourceElement.value; } window.handleSliderChange(); },
        toggleCollapse: (header) => { const content = document.getElementById(header.dataset.target); content.classList.toggle('collapsed'); header.classList.toggle('collapsed'); }
    };

    // --- DATA & CONFIG MODULES ---
    const GristDataManager = (() => { const state = { tables: null, columns: null }; let _tableMeta = null; async function _loadMeta() { if (!state.tables) state.tables = await grist.docApi.fetchTable('_grist_Tables'); if (!state.columns) state.columns = await grist.docApi.fetchTable('_grist_Tables_column'); } function _getNumericTableId(tableId) { const idx = state.tables.tableId.findIndex(t => t === tableId); return idx !== -1 ? state.tables.id[idx] : null; } function _getColumnsForTable(numericId) { const cols = []; const allCols = state.columns; const parentIdKey = allCols.parentId ? 'parentId' : 'tableId'; const colIdKey = allCols.colId ? 'colId' : 'columnId'; for (let i = 0; i < allCols.id.length; i++) { if (allCols[parentIdKey][i] === numericId) { cols.push({ id: allCols[colIdKey][i], label: allCols.label[i], type: allCols.type[i], isFormula: !!allCols.formula[i], formula: allCols.formula[i] || '' }); } } return cols; } async function fetchAll() { try { const tableId = await grist.selectedTable.getTableId(); if (!tableId) throw new Error("No table is linked to this widget."); await _loadMeta(); const numericId = _getNumericTableId(tableId); if (!numericId) throw new Error(`Could not find metadata for table: ${tableId}`); _tableMeta = { id: tableId, columns: _getColumnsForTable(numericId) }; return { tableMeta: _tableMeta }; } catch (e) { console.error("GDM Error:", e); _tableMeta = null; throw e; } } return { fetchAll, getTableMeta: () => _tableMeta }; })();
    const WidgetConfigManager = (() => { const DEFAULTS = { formulaColumnId: null, variableSettings: {}, showChart: true, compactMode: false, widgetTitle: "Scenario Simulator", titleColumnId: null, debugMode: false }; let _currentConfig = { ...DEFAULTS }; async function loadConfig() { const stored = await grist.getOption(WIDGET_CONFIG_KEY); _currentConfig = { ...DEFAULTS, ...(stored || {}) }; return _currentConfig; } async function saveConfig(config) { _currentConfig = { ..._currentConfig, ...config }; await grist.setOption(WIDGET_CONFIG_KEY, _currentConfig); } async function clearConfig() { _currentConfig = { ...DEFAULTS }; await grist.setOption(WIDGET_CONFIG_KEY, null); } return { loadConfig, saveConfig, getConfig: () => _currentConfig, clearConfig }; })();

    // --- DOM ELEMENTS & APP STATE ---
    const dom = { body: document.body, errorMsg: document.getElementById('error-msg'), configScreen: document.getElementById('config-screen'), simulationScreen: document.getElementById('simulation-screen'), formulaSelect: document.getElementById('formula-column-select'), variablesStep: document.getElementById('variables-step'), variableConfigContainer: document.getElementById('variable-config-container'), saveConfigBtn: document.getElementById('save-config-btn'), resetConfigBtn: document.getElementById('reset-config-btn'), reconfigBtn: document.getElementById('reconfig-btn'), resultLabel: document.getElementById('result-label'), resultValue: document.getElementById('result-value'), slidersContainer: document.getElementById('sliders-container'), formulasContainer: document.getElementById('formulas-container'), chartContainer: document.getElementById('chart-container'), chartCanvas: document.getElementById('sensitivity-chart'), xAxisSelect: document.getElementById('xaxis-select'), showChartToggle: document.getElementById('show-chart-toggle'), compactModeToggle: document.getElementById('compact-mode-toggle'), widgetTitleInput: document.getElementById('widget-title-input'), titleColumnSelect: document.getElementById('title-column-select'), simulationTitle: document.getElementById('simulation-title'), saveToGristBtn: document.getElementById('save-to-grist-btn'), debugContainer: document.getElementById('debug-container'), debugLog: document.getElementById('debug-log'), debugModeToggle: document.getElementById('debug-mode-toggle'), clearLogBtn: document.getElementById('clear-log-btn') };
    let state = { formula: null, evaluator: null, varIds: [], varMeta: {}, record: null, chart: null, isReady: false };
    const log = (message, data = null) => { if (!WidgetConfigManager.getConfig().debugMode) return; const logEl = document.getElementById('debug-log'); const timestamp = new Date().toLocaleTimeString(); const entry = document.createElement('div'); entry.innerHTML = `<strong>[${timestamp}] ${message}</strong>`; if (data) { const dataEl = document.createElement('span'); dataEl.textContent = JSON.stringify(data, null, 2); entry.appendChild(dataEl); } logEl.appendChild(entry); logEl.scrollTop = logEl.scrollHeight; };

    // --- CORE LOGIC (All stable, no changes) ---
    const tokenizeFormula = (formula) => { const regex = /\$[\w_]+|-?\d*\.?\d+|\*\*|[()*/+-]/g; return formula.match(regex) || []; };
    const parseFormulaForVariables = (formula) => { const matches = formula.match(/\$(\w+)/g); return matches ? [...new Set(matches.map(v => v.substring(1)))] : []; };
    const buildFormulaEvaluator = (formula) => { if (!formula) return null; let body = formula.replace(/\$(\w+)/g, '$1'); try { return new Function('data', `with (data) { return ${body}; }`); } catch (e) { showError(`The formula "${formula}" appears to be invalid.`); return null; } };
    const setupChart = () => { if (state.chart) state.chart.destroy(); const ctx = dom.chartCanvas.getContext('2d'); state.chart = new Chart(ctx, { type: 'line', data: { labels: [], datasets: [{ label: 'Result', data: [], borderColor: 'rgba(13, 110, 253, 0.8)', backgroundColor: 'rgba(13, 110, 253, 0.1)', fill: true, tension: 0.1, pointRadius: DEFAULT_RADIUS }] }, options: { responsive: true, maintainAspectRatio: false, interaction: { intersect: false, mode: 'index' } } }); };
    const updateChart = () => { if (!state.chart || !WidgetConfigManager.getConfig().showChart) { dom.chartContainer.classList.add('hidden'); return; } const xAxisVarId = dom.xAxisSelect.value; if (!xAxisVarId) { dom.chartContainer.classList.add('hidden'); return; } const config = WidgetConfigManager.getConfig(); const xAxisSettings = config.variableSettings[xAxisVarId]; if (!xAxisSettings || typeof xAxisSettings.min === 'undefined') return; const labels = [], dataPoints = []; const { min, max, step: userStep } = xAxisSettings; if (userStep <= 0) { dom.chartContainer.classList.add('hidden'); return; } const MAX_POINTS_TO_PLOT = 101; const totalPossiblePoints = Math.floor((max - min) / userStep) + 1; let effectiveStep = userStep; if (totalPossiblePoints > MAX_POINTS_TO_PLOT) { const samplingFactor = Math.ceil(totalPossiblePoints / MAX_POINTS_TO_PLOT); effectiveStep = userStep * samplingFactor; } const baseArgs = {}; state.varIds.forEach(varId => { if (varId === xAxisVarId) return; const settings = config.variableSettings[varId]; if (settings.isLocked) { baseArgs[varId] = settings.lockMode === 'table' && state.record ? state.record[varId] : settings.lockValue; } else { baseArgs[varId] = parseFloat(document.getElementById(`number-${varId}`).value); } }); for (let currentX = min; currentX <= max; currentX += effectiveStep) { labels.push(currentX); const loopArgs = { ...baseArgs, [xAxisVarId]: currentX }; try { dataPoints.push(state.evaluator(loopArgs)); } catch { dataPoints.push(null); } } const lastLabelValue = labels.length > 0 ? labels[labels.length - 1] : min; if (lastLabelValue < max) { labels.push(max); const finalArgs = { ...baseArgs, [xAxisVarId]: max }; try { dataPoints.push(state.evaluator(finalArgs)); } catch { dataPoints.push(null); } } const currentXValue = parseFloat(document.getElementById(`number-${xAxisVarId}`).value); let closestPointIndex = -1; if (labels.length > 0) { closestPointIndex = labels.reduce((closestIdx, currentVal, currentIdx) => { const closestDistance = Math.abs(labels[closestIdx] - currentXValue); const currentDistance = Math.abs(currentVal - currentXValue); return currentDistance < closestDistance ? currentIdx : closestIdx; }, 0); } const pointRadii = new Array(labels.length).fill(DEFAULT_RADIUS); const pointBackgroundColors = new Array(labels.length).fill(state.chart.data.datasets[0].borderColor); if (closestPointIndex !== -1) { pointRadii[closestPointIndex] = HIGHLIGHT_RADIUS; pointBackgroundColors[closestPointIndex] = HIGHLIGHT_COLOR; } state.chart.data.labels = labels.map(l => l.toLocaleString()); state.chart.data.datasets[0].data = dataPoints; state.chart.data.datasets[0].pointRadius = pointRadii; state.chart.data.datasets[0].pointBackgroundColor = pointBackgroundColors; const resultLabel = dom.resultLabel.textContent.split('(')[1]?.split(')')[0] || 'Result'; state.chart.data.datasets[0].label = `${resultLabel} vs ${state.varMeta[xAxisVarId].label}`; state.chart.update(); dom.chartContainer.classList.remove('hidden'); };
    const recalculateAndDisplay = () => { if (!state.evaluator) return; const config = WidgetConfigManager.getConfig(); const argObject = {}; state.varIds.forEach(varId => { const settings = config.variableSettings[varId]; if (settings.isLocked) { argObject[varId] = settings.lockMode === 'table' && state.record ? state.record[varId] : settings.lockValue; } else { argObject[varId] = parseFloat(document.getElementById(`number-${varId}`).value); } }); try { const result = state.evaluator(argObject); dom.resultValue.textContent = typeof result === 'number' ? result.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : String(result); } catch (e) { dom.resultValue.textContent = "Calculation Error"; } updateLiveValuesDisplay(); updateChart(); };
    window.handleSliderChange = recalculateAndDisplay;
    const showScreen = (screen) => { dom.configScreen.classList.add('hidden'); dom.simulationScreen.classList.add('hidden'); if (screen === 'config') dom.configScreen.classList.remove('hidden'); else if (screen === 'simulation') dom.simulationScreen.classList.remove('hidden'); };
    const showError = (message) => { dom.errorMsg.textContent = message; dom.errorMsg.classList.remove('hidden'); dom.configScreen.classList.add('hidden'); dom.simulationScreen.classList.add('hidden'); };
    const renderFormulaPills = () => { dom.formulasContainer.innerHTML = `<div class="collapsible-header" data-target="formula-display-content" onclick="window.globalWidgetFunctions.toggleCollapse(this)"><span class="arrow">▼</span> Formula</div><div id="formula-display-content" class="collapsible-content"><div class="formula-display-box" id="formula-display-container"></div><div class="formula-display-box" id="live-values-container"></div></div>`; dom.formulaDisplay = document.getElementById('formula-display-container'); dom.liveValues = document.getElementById('live-values-container'); const parts = tokenizeFormula(state.formula); const varCounters = {}; parts.forEach(part => { let pill, op; if (part.startsWith('$')) { const varId = part.substring(1); const variable = state.varMeta[varId]; if (varCounters[varId] === undefined) { varCounters[varId] = 0; } if (variable) { pill = document.createElement('span'); pill.className = 'pill'; pill.textContent = variable.label; pill.style.backgroundColor = variable.color; dom.formulaDisplay.appendChild(pill); pill = document.createElement('span'); pill.className = 'pill'; pill.id = `live-pill-${varId}-${varCounters[varId]}`; pill.textContent = '...'; pill.style.backgroundColor = variable.color; dom.liveValues.appendChild(pill); varCounters[varId]++; } } else { op = document.createElement('span'); op.className = 'formula-operator'; op.textContent = ` ${part} `; dom.formulaDisplay.appendChild(op); op = document.createElement('span'); op.className = 'formula-operator'; op.textContent = ` ${part} `; dom.liveValues.appendChild(op); } }); };
    const updateLiveValuesDisplay = () => { const config = WidgetConfigManager.getConfig(); state.varIds.forEach(varId => { let value; const settings = config.variableSettings[varId]; if (settings.isLocked) { value = settings.lockMode === 'table' && state.record ? state.record[varId] : settings.lockValue; } else { const numInput = document.getElementById(`number-${varId}`); value = numInput ? parseFloat(numInput.value) : 0; } const allPillsForVar = document.querySelectorAll(`[id^="live-pill-${varId}-"]`); allPillsForVar.forEach(pill => { pill.textContent = (value ?? 0).toLocaleString(); }); }); };
    const buildVariableConfigUI = (variableIds, existingSettings = {}) => { dom.variableConfigContainer.innerHTML = ''; const tableMeta = GristDataManager.getTableMeta(); if (!variableIds.length) { dom.variablesStep.classList.add('hidden'); dom.saveConfigBtn.disabled = true; return; } variableIds.forEach(varId => { const column = tableMeta.columns.find(c => c.id === varId); if (!column) return; const settings = existingSettings[varId] || {}; const row = document.createElement('div'); row.className = 'variable-config-row'; row.dataset.variableId = varId; row.innerHTML = `<div class="variable-header"><strong>Variable: ${column.label}</strong><label class="config-toggle"><input type="checkbox" class="lock-checkbox" onchange="window.globalWidgetFunctions.handleLockChange(this)" ${settings.isLocked ? 'checked' : ''}> Lock</label></div><div class="variable-inputs"><div><label for="min-${varId}">Min</label><input type="number" id="min-${varId}" value="${settings.min ?? ''}" placeholder="e.g. 0"></div><div><label for="max-${varId}">Max</label><input type="number" id="max-${varId}" value="${settings.max ?? ''}" placeholder="e.g. 1000"></div><div><label for="step-${varId}">Step</label><input type="number" id="step-${varId}" value="${settings.step ?? ''}" placeholder="e.g. 1"></div></div><div class="lock-options ${settings.isLocked ? '' : 'hidden'}"><label><input type="radio" name="lock-mode-${varId}" value="table" ${settings.lockMode === 'custom' ? '' : 'checked'}> Use value from table</label><label style="display: flex; align-items: center; gap: 0.5rem; margin-top: 0.5rem;"><input type="radio" name="lock-mode-${varId}" value="custom" ${settings.lockMode === 'custom' ? 'checked' : ''}> Use fixed value:<input type="number" class="lock-value-input" value="${settings.lockValue ?? 0}" style="flex-grow:1;"></label></div>`; dom.variableConfigContainer.appendChild(row); }); dom.variablesStep.classList.remove('hidden'); dom.saveConfigBtn.disabled = false; };
    const initializeConfigScreen = (prefill = false) => { const tableMeta = GristDataManager.getTableMeta(); const config = WidgetConfigManager.getConfig(); const formulaColumns = tableMeta.columns.filter(c => c.isFormula); dom.formulaSelect.innerHTML = '<option value="">-- Please select a formula --</option>'; formulaColumns.forEach(col => dom.formulaSelect.add(new Option(`${col.label} (${col.id})`, col.id))); dom.titleColumnSelect.innerHTML = '<option value="">-- None (use Record ID) --</option>'; tableMeta.columns.forEach(col => { if (!col.isFormula) dom.titleColumnSelect.add(new Option(col.label, col.id)); }); dom.widgetTitleInput.value = config.widgetTitle; dom.titleColumnSelect.value = config.titleColumnId || ''; dom.showChartToggle.checked = config.showChart; dom.compactModeToggle.checked = config.compactMode; dom.debugModeToggle.checked = config.debugMode; if (prefill && config.formulaColumnId) { dom.formulaSelect.value = config.formulaColumnId; const formulaColumn = tableMeta.columns.find(c => c.id === config.formulaColumnId); buildVariableConfigUI(parseFormulaForVariables(formulaColumn.formula), config.variableSettings); } else { dom.variablesStep.classList.add('hidden'); dom.saveConfigBtn.disabled = true; } showScreen('config'); };
    const initializeSimulationScreen = () => { const config = WidgetConfigManager.getConfig(); const tableMeta = GristDataManager.getTableMeta(); const formulaColumn = tableMeta.columns.find(c => c.id === config.formulaColumnId); dom.body.classList.toggle('compact', config.compactMode); dom.debugContainer.classList.toggle('hidden', !config.debugMode); if (config.compactMode) { const formulaLabel = formulaColumn.label.replace(/_/g, ' '); dom.resultLabel.textContent = `${formulaLabel} =`; } else { dom.resultLabel.textContent = `Result (${formulaColumn.label})`; } state.varIds = Object.keys(config.variableSettings); state.formula = formulaColumn.formula; state.varMeta = {}; state.varIds.forEach((varId, index) => { const column = tableMeta.columns.find(c => c.id === varId); state.varMeta[varId] = { id: varId, label: column.label, color: PALETTE[index % PALETTE.length] }; }); state.evaluator = buildFormulaEvaluator(state.formula); if (!state.evaluator) { WidgetConfigManager.clearConfig(); main(); return; } renderFormulaPills(); if (config.showChart) { dom.chartContainer.innerHTML = `<div class="collapsible-header" data-target="chart-content" onclick="window.globalWidgetFunctions.toggleCollapse(this)"><span class="arrow">▼</span> Analysis Chart</div><div id="chart-content" class="collapsible-content"><div id="chart-controls"><label for="xaxis-select">Analyze on X-Axis:</label><select id="xaxis-select"></select></div><div id="chart-wrapper"><canvas id="sensitivity-chart"></canvas></div></div>`; dom.xAxisSelect = document.getElementById('xaxis-select'); dom.chartCanvas = document.getElementById('sensitivity-chart'); state.varIds.forEach(varId => { if (!config.variableSettings[varId].isLocked) dom.xAxisSelect.add(new Option(state.varMeta[varId].label, varId)); }); dom.xAxisSelect.addEventListener('change', updateChart); setupChart(); } else { dom.chartContainer.innerHTML = ''; } dom.resultValue.textContent = "-- select a record --"; dom.slidersContainer.innerHTML = ''; state.varIds.forEach(varId => { const settings = config.variableSettings[varId]; if (settings.isLocked) return; const group = document.createElement('div'); group.className = 'slider-group'; group.innerHTML = `<label for="slider-${varId}" class="slider-label"><span class="pill" style="background-color: ${state.varMeta[varId].color};">${state.varMeta[varId].label}</span></label><div class="input-with-slider"><input type="range" id="slider-${varId}" data-variable-id="${varId}" min="${settings.min}" max="${settings.max}" step="${settings.step}" oninput="window.globalWidgetFunctions.updateSliderAndInput(this)"><input type="number" id="number-${varId}" data-variable-id="${varId}" min="${settings.min}" max="${settings.max}" step="${settings.step}" oninput="window.globalWidgetFunctions.updateSliderAndInput(this)"></div>`; dom.slidersContainer.appendChild(group); }); showScreen('simulation'); };

    // --- EVENT HANDLERS ---
    dom.formulaSelect.addEventListener('change', () => { const selectedColumnId = dom.formulaSelect.value; if (!selectedColumnId) { dom.variablesStep.classList.add('hidden'); dom.saveConfigBtn.disabled = true; return; } const formulaColumn = GristDataManager.getTableMeta().columns.find(c => c.id === selectedColumnId); buildVariableConfigUI(parseFormulaForVariables(formulaColumn.formula)); });
    dom.saveConfigBtn.addEventListener('click', async () => { const formulaColumnId = dom.formulaSelect.value; const variableSettings = {}; let isValid = true; dom.variableConfigContainer.querySelectorAll('.variable-config-row').forEach(row => { const varId = row.dataset.variableId; const isLocked = row.querySelector('.lock-checkbox').checked; const minText = row.querySelector(`#min-${varId}`).value; const maxText = row.querySelector(`#max-${varId}`).value; const stepText = row.querySelector(`#step-${varId}`).value; const min = minText === '' ? 0 : parseFloat(minText); const max = maxText === '' ? 100 : parseFloat(maxText); const step = stepText === '' ? 1 : parseFloat(stepText); if (!isLocked && (isNaN(min) || isNaN(max) || isNaN(step))) { isValid = false; } variableSettings[varId] = { isLocked: isLocked, min: min, max: max, step: step, lockMode: row.querySelector('input[name="lock-mode-'+varId+'"]:checked').value, lockValue: parseFloat(row.querySelector('.lock-value-input').value) }; }); if (!isValid) { alert("Please ensure Min, Max, and Step are valid numbers for all unlocked variables."); return; } const showChart = dom.showChartToggle.checked; const compactMode = dom.compactModeToggle.checked; const widgetTitle = dom.widgetTitleInput.value; const titleColumnId = dom.titleColumnSelect.value; const debugMode = dom.debugModeToggle.checked; await WidgetConfigManager.saveConfig({ formulaColumnId, variableSettings, showChart, compactMode, widgetTitle, titleColumnId, debugMode }); initializeSimulationScreen(); if (state.record) onRecordHandler(state.record); });
    dom.reconfigBtn.addEventListener('click', () => initializeConfigScreen(true));
    dom.resetConfigBtn.addEventListener('click', async () => { if (confirm("Are you sure you want to start over? All settings will be lost.")) { await WidgetConfigManager.clearConfig(); initializeConfigScreen(false); } });
    dom.clearLogBtn.addEventListener('click', () => { dom.debugLog.innerHTML = ''; });
    dom.simulationScreen.addEventListener('click', (e) => { if (e.target.matches('.collapsible-header')) { window.globalWidgetFunctions.toggleCollapse(e.target); } });
    dom.saveToGristBtn.addEventListener('click', async () => { if (!state.record) { alert("Please select a record in the Grist table first."); return; } const config = WidgetConfigManager.getConfig(); const table = grist.getTable(GristDataManager.getTableMeta().id); const valuesToUpdate = {}; let updatedCount = 0; state.varIds.forEach(varId => { const settings = config.variableSettings[varId]; if (!settings.isLocked) { const currentValue = parseFloat(document.getElementById(`number-${varId}`).value); if (state.record[varId] !== currentValue) { valuesToUpdate[varId] = currentValue; updatedCount++; } } }); if (updatedCount > 0) { try { await table.update({id: state.record.id, fields: valuesToUpdate}); dom.saveToGristBtn.innerHTML = `✓ Saved!`; setTimeout(() => { dom.saveToGristBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M2 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H9.5a1 1 0 0 0-1 1v4.5h2a.5.5 0 0 1 .354.854l-2.5 2.5a.5.5 0 0 1-.708 0l-2.5-2.5A.5.5 0 0 1 5.5 6.5h2V2a2 2 0 0 1 2-2H14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h2.5a.5.5 0 0 1 0 1H2z"/></svg> Save to Grist`; }, 2000); } catch (err) { alert(`Error saving to Grist: ${err.message}`); } } else { alert("No changes to save. Slider values already match the Grist record."); } });

    // --- GRIST LISTENERS & INITIALIZATION ---
    const onRecordHandler = (record) => {
        if (!state.isReady) { return; } // Do nothing if the widget isn't fully initialized
        log("onRecordHandler received:", record);
        state.record = record;
        const config = WidgetConfigManager.getConfig();
        const titleSuffix = config.titleColumnId && record ? record[config.titleColumnId] : (record ? `ID: ${record.id}`: '');
        dom.simulationTitle.textContent = `${config.widgetTitle} - ${titleSuffix}`;
        if (!record || dom.simulationScreen.classList.contains('hidden')) {
            if(!dom.simulationScreen.classList.contains('hidden')) dom.resultValue.textContent = "-- select a record --";
            dom.saveToGristBtn.disabled = true;
            return;
        }
        dom.saveToGristBtn.disabled = false;
        state.varIds.forEach(varId => {
            log(`Processing variable: ${varId}`);
            const settings = config.variableSettings[varId]; if (settings.isLocked) { log(` -> ${varId} is locked. Skipping slider update.`); return; }
            const slider = document.getElementById(`slider-${varId}`); const numberInput = document.getElementById(`number-${varId}`);
            if (slider && numberInput) {
                const recordValue = record[varId] ?? 0;
                log(` -> Value from record for ${varId}:`, recordValue);
                const value = Math.max(settings.min, Math.min(settings.max, recordValue));
                log(` -> Clamped value for ${varId}:`, value);
                slider.value = value; numberInput.value = value;
            } else { log(` -> Slider or number input not found for ${varId}`); }
        });
        recalculateAndDisplay();
    };
    
    // --- THIS IS THE NEW, ROBUST INITIALIZATION ---
    async function main() {
        dom.errorMsg.classList.add('hidden');
        try {
            // First, fetch all data and configuration. This is the critical step.
            await GristDataManager.fetchAll();
            const config = await WidgetConfigManager.loadConfig();
            
            // Now that we have all the info, attach the event listeners.
            grist.onRecord(onRecordHandler);
            grist.onRecords(() => {
                log("onRecords triggered. Re-initializing.");
                main(); // Re-run the main function if the whole table changes
            });

            // Now, build the UI based on the loaded config.
            if (config.formulaColumnId) {
                initializeSimulationScreen();
            } else {
                initializeConfigScreen();
            }
            state.isReady = true; // Mark the widget as fully initialized
            log("Widget is ready.");

        } catch (e) {
            showError(e.message);
        }
    }

    grist.ready({ requiredAccess: 'full' });
    main(); // Start the main initialization process
});

This last code above adds debug mode (if needed)

1 Like