GigaProjects

← Back to WealthDist

main.js

import { MultiYearSimulation } from './simulation.js';
import Chart from 'chart.js/auto';

function gaussianKernel(x) {
    return (1 / Math.sqrt(2 * Math.PI)) * Math.exp(-0.5 * x * x);
}

function computeKDE(data, bandwidth, range, steps = 220) {
    const min = range[0];
    const max = range[1];
    const step = (max - min) / steps;
    const density = [];
    const labels = [];
    const n = data.length;

    for (let i = 0; i <= steps; i++) {
        const x = min + i * step;
        let sum = 0;
        for (let j = 0; j < n; j++) {
            const u = (x - data[j]) / bandwidth;
            if (u > -4 && u < 4) sum += gaussianKernel(u);
        }
        density.push(sum / (n * bandwidth));
        labels.push(x);
    }
    return { x: labels, y: density };
}

function smoothSeries(series, radius = 2) {
    if (!Array.isArray(series) || radius <= 0) return series;
    return series.map((_, index) => {
        let sum = 0;
        let count = 0;
        const start = Math.max(0, index - radius);
        const end = Math.min(series.length - 1, index + radius);
        for (let i = start; i <= end; i++) {
            sum += series[i];
            count += 1;
        }
        return sum / count;
    });
}

const currencyFormatter = new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
    maximumFractionDigits: 0,
    notation: 'compact'
});

const defaults = {
    years: 50,
    yield: 5.5,
    taxCap: false,
    multiplier: 7,
    distType: 'lognormal',
    mean: 50000,
    sigma: 1.0,
    shape: 2.0,
    timeline: 50,
    ubiA: 10,
    deductionB: 30000,
    ubiB: 100,
    capitalTaxB: 35,
    displacement: 5.0,
    efficiency: 1.0
};

const inputs = {
    years: document.getElementById('years'),
    yield: document.getElementById('yield'),
    taxCap: document.getElementById('tax-cap'),
    multiplier: document.getElementById('multiplier'),
    distType: document.getElementById('dist-type'),
    mean: document.getElementById('mean-income'),
    sigma: document.getElementById('sigma'),
    shape: document.getElementById('shape'),
    timeline: document.getElementById('timeline'),
    ubiA: document.getElementById('ubi-a'),
    deductionB: document.getElementById('deduction-b'),
    ubiB: document.getElementById('ubi-b'),
    capitalTaxB: document.getElementById('capital-tax-b'),
    displacement: document.getElementById('displacement'),
    efficiency: document.getElementById('efficiency')
};

const displays = {
    years: document.getElementById('val-years'),
    yield: document.getElementById('val-yield'),
    multiplier: document.getElementById('val-multiplier'),
    mean: document.getElementById('val-mean'),
    sigma: document.getElementById('val-sigma'),
    shape: document.getElementById('val-shape'),
    timeline: document.getElementById('val-timeline'),
    ubiA: document.getElementById('val-ubi-a'),
    deductionB: document.getElementById('val-deduction-b'),
    ubiB: document.getElementById('val-ubi-b'),
    capitalTaxB: document.getElementById('val-capital-tax-b'),
    displacement: document.getElementById('val-displacement'),
    efficiency: document.getElementById('val-efficiency'),
    fundingGap: document.getElementById('funding-gap'),
    ubiGap: document.getElementById('ubi-gap'),
    winnerTop1: document.getElementById('winner-top1'),
    winnerEmployment: document.getElementById('winner-employment')
};

let activeBrackets = {
    A: [{ threshold: 0, rate: 0.1 }, { threshold: 47000, rate: 0.22 }, { threshold: 100000, rate: 0.3 }],
    B: [{ threshold: 0, rate: 0.1 }, { threshold: 50000, rate: 0.35 }, { threshold: 120000, rate: 0.55 }, { threshold: 300000, rate: 0.72 }, { threshold: Infinity, rate: 0.72 }]
};
let currentEditingPolicy = 'A';
let globalResults = null;
let distChart = null;
let giniChart = null;
let shareChart = null;
let timelineTimer = null;

const modal = document.getElementById('bracket-modal');
const helpModal = document.getElementById('help-modal');
const helpTitle = document.getElementById('help-title');
const helpBody = document.getElementById('help-body');
const bracketList = document.getElementById('bracket-list');
const resetButton = document.getElementById('reset-defaults');
const shareButton = document.getElementById('share-scenario');
const playButton = document.getElementById('timeline-play');
const startButton = document.getElementById('timeline-start');
const endButton = document.getElementById('timeline-end');

const helpCopy = {
    model: {
        title: 'Model Info',
        body: [
            ['Core idea', 'AI reduces labor income and raises capital returns. Current tax policy leans heavily on wages.'],
            ['Comparison', 'The chart compares today-style taxation with a new policy that also taxes AI-driven capital returns.'],
            ['Metrics', 'Funding Gap shows revenue shortfall. UBI Capacity shows per-person redistribution.']
        ]
    },
    labor: {
        title: 'Labor Replaced',
        body: [
            ['What it means', 'How much labor income disappears each year as AI automates work.'],
            ['Why it matters', 'A wage-heavy tax system collects less when wages shrink.']
        ]
    },
    capital: {
        title: 'Capital Growth',
        body: [
            ['What it means', 'Extra yearly return flowing to owners of AI, software, and capital.'],
            ['Why it matters', 'If the growing income is capital income, policy needs a capital-side tax base.']
        ]
    },
    'ai-tax': {
        title: 'AI / Capital Tax',
        body: [
            ['What it means', 'A tax on AI-driven capital returns, used here to fund a broad UBI.'],
            ['Simplification', 'This is not a legal proposal. It is a model lever for testing the tax-base shift.']
        ]
    },
    'funding-gap': {
        title: 'Funding Gap',
        body: [
            ['What it means', 'How much less the current system raises compared with the new AI/capital tax policy.'],
            ['How to read it', 'A bigger gap means the old labor-focused base is becoming less adequate.']
        ]
    },
    ubi: {
        title: 'UBI Capacity',
        body: [
            ['What it means', 'The per-person redistribution supported by current tax revenue versus the new policy.'],
            ['Why it matters', 'It turns abstract tax revenue into a concrete household-facing number.']
        ]
    }
};

function setInputValue(key, value) {
    if (!(key in inputs) || value === undefined) return;
    const el = inputs[key];
    if (el.type === 'checkbox') el.checked = Boolean(value);
    else el.value = value;
}

function applyState(state, { updateTimeline = true } = {}) {
    Object.entries(state).forEach(([key, value]) => setInputValue(key, value));
    if (updateTimeline && state.years !== undefined) {
        const nextYear = Math.min(Number(inputs.timeline.value), Number(state.years));
        inputs.timeline.value = state.timeline ?? nextYear;
    }
    stopTimelinePlayback();
    updateUI();
    runSimulation();
}

function loadDefaults() {
    applyState(defaults, { updateTimeline: true });
}

function getScenarioState() {
    return {
        years: Number(inputs.years.value),
        yield: Number(inputs.yield.value),
        taxCap: inputs.taxCap.checked,
        multiplier: Number(inputs.multiplier.value),
        distType: inputs.distType.value,
        mean: Number(inputs.mean.value),
        sigma: Number(inputs.sigma.value),
        shape: Number(inputs.shape.value),
        timeline: Number(inputs.timeline.value),
        ubiA: Number(inputs.ubiA.value),
        deductionB: Number(inputs.deductionB.value),
        ubiB: Number(inputs.ubiB.value),
        capitalTaxB: Number(inputs.capitalTaxB.value),
        displacement: Number(inputs.displacement.value),
        efficiency: Number(inputs.efficiency.value)
    };
}

function writeScenarioToUrl() {
    const state = getScenarioState();
    const params = new URLSearchParams();
    Object.entries(state).forEach(([key, value]) => params.set(key, String(value)));
    history.replaceState(null, '', `${window.location.pathname}?${params.toString()}`);
}

function parseScenarioFromUrl() {
    const params = new URLSearchParams(window.location.search);
    if (!params.toString()) return null;

    const state = {};
    for (const [key, defaultValue] of Object.entries(defaults)) {
        if (!params.has(key)) continue;
        const raw = params.get(key);
        if (typeof defaultValue === 'boolean') state[key] = raw === 'true';
        else if (typeof defaultValue === 'number') state[key] = Number(raw);
        else state[key] = raw;
    }
    return { state };
}

function updateUI() {
    const groupShape = document.getElementById('group-shape');
    const groupMean = document.getElementById('group-mean');
    const groupSigma = document.getElementById('group-sigma');

    if (inputs.distType.value === 'pareto') {
        groupMean.style.display = 'none';
        groupSigma.style.display = 'none';
        groupShape.style.display = 'flex';
    } else {
        groupMean.style.display = 'flex';
        groupSigma.style.display = 'flex';
        groupShape.style.display = 'none';
    }
}

function openBracketModal(policy) {
    currentEditingPolicy = policy;
    if (activeBrackets[policy].length === 0) {
        activeBrackets[policy] = [{ threshold: 0, rate: 0.1 }, { threshold: 50000, rate: 0.2 }];
    }
    renderBrackets();
    modal.style.display = 'flex';
}

function renderBrackets() {
    bracketList.innerHTML = '';
    activeBrackets[currentEditingPolicy].forEach((b, index) => {
        const row = document.createElement('div');
        row.className = 'bracket-row';
        const thresholdValue = Number.isFinite(b.threshold) ? b.threshold : '';
        row.innerHTML = `
            <span>Above</span>
            <input type="number" value="${thresholdValue}" placeholder="No limit" onchange="updateBracket(${index}, 'threshold', this.value)">
            <span>at</span>
            <input type="number" value="${b.rate * 100}" onchange="updateBracket(${index}, 'rate', this.value)">
            <span>%</span>
            <button class="remove-bracket" onclick="removeBracket(${index})" type="button">&times;</button>
        `;
        bracketList.appendChild(row);
    });
}

window.updateBracket = (index, field, value) => {
    const bracket = activeBrackets[currentEditingPolicy][index];
    if (field === 'rate') bracket.rate = parseFloat(value) / 100;
    if (field === 'threshold') bracket.threshold = value === '' ? Infinity : parseFloat(value);
};

window.removeBracket = (index) => {
    activeBrackets[currentEditingPolicy].splice(index, 1);
    renderBrackets();
};

document.getElementById('add-bracket').onclick = () => {
    activeBrackets[currentEditingPolicy].push({ threshold: 0, rate: 0.0 });
    renderBrackets();
};

document.getElementById('edit-brackets-b').onclick = () => {
    openBracketModal('B');
};

document.getElementById('close-modal').onclick = () => {
    modal.style.display = 'none';
};

document.getElementById('close-help').onclick = () => {
    helpModal.style.display = 'none';
};

document.getElementById('apply-brackets').onclick = () => {
    modal.style.display = 'none';
    runSimulation();
};

function openHelp(topic) {
    const content = helpCopy[topic];
    if (!content) return;

    helpTitle.textContent = content.title;
    helpBody.innerHTML = content.body.map(([title, text]) => `
        <div>
            <div class="explanatory-overlay__title">${title}</div>
            <div>${text}</div>
        </div>
    `).join('');
    helpModal.style.display = 'flex';
}

function initCharts() {
    const commonLegend = {
        labels: {
            color: '#5f564c',
            usePointStyle: true,
            pointStyle: 'circle',
            boxWidth: 10,
            padding: 14
        }
    };

    const commonScales = {
        xGrid: { color: 'rgba(60, 44, 26, 0.08)' },
        yGrid: { color: 'rgba(60, 44, 26, 0.08)' },
        tick: { color: '#5f564c' },
        title: { color: '#5f564c' }
    };

    const ctxDist = document.getElementById('distChart').getContext('2d');
    distChart = new Chart(ctxDist, {
        type: 'line',
        data: {
            labels: [],
            datasets: [
                { label: 'Current Tax System', data: [], borderColor: '#b34a3c', backgroundColor: 'rgba(179, 74, 60, 0.12)', fill: true, pointRadius: 0, pointHitRadius: 14, borderWidth: 2, tension: 0.46, cubicInterpolationMode: 'monotone' },
                { label: 'New AI / Capital Tax', data: [], borderColor: '#3c8d40', backgroundColor: 'rgba(60, 141, 64, 0.14)', fill: true, pointRadius: 0, pointHitRadius: 14, borderWidth: 2, tension: 0.46, cubicInterpolationMode: 'monotone' }
            ]
        },
        options: {
            responsive: true,
            maintainAspectRatio: false,
            scales: {
                x: {
                    type: 'linear',
                    grid: commonScales.xGrid,
                    ticks: { ...commonScales.tick, callback: (val) => currencyFormatter.format(val) }
                },
                y: {
                    display: false,
                    grid: commonScales.yGrid,
                    ticks: commonScales.tick
                }
            },
            plugins: {
                legend: { display: false },
                tooltip: { mode: 'nearest', intersect: true }
            },
            interaction: { mode: 'nearest', intersect: true }
        }
    });

    const ctxGini = document.getElementById('giniChart').getContext('2d');
    giniChart = new Chart(ctxGini, {
        type: 'line',
        data: {
            labels: [],
            datasets: [
                { label: 'Current Tax System', data: [], borderColor: '#b34a3c', pointRadius: 0, pointHitRadius: 14, borderWidth: 2, tension: 0.32, cubicInterpolationMode: 'monotone' },
                { label: 'New AI / Capital Tax', data: [], borderColor: '#3c8d40', pointRadius: 0, pointHitRadius: 14, borderWidth: 2, tension: 0.32, cubicInterpolationMode: 'monotone' }
            ]
        },
        options: {
            responsive: true,
            maintainAspectRatio: false,
            scales: {
                x: {
                    grid: commonScales.xGrid,
                    ticks: commonScales.tick,
                    title: { display: true, text: 'Years', color: commonScales.title.color }
                },
                y: {
                    min: 0,
                    max: 1,
                    grid: commonScales.yGrid,
                    ticks: commonScales.tick,
                    title: { display: true, text: 'Gini Coefficient', color: commonScales.title.color }
                }
            },
            plugins: {
                legend: { display: false },
                tooltip: { mode: 'nearest', intersect: true }
            },
            interaction: { mode: 'nearest', intersect: true }
        }
    });

    const ctxShare = document.getElementById('shareChart').getContext('2d');
    shareChart = new Chart(ctxShare, {
        type: 'bar',
        data: {
            labels: ['Top 1%', 'Top 10%', 'Bottom 50%'],
            datasets: [
                { label: 'Year 0', data: [], backgroundColor: 'rgba(95, 86, 76, 0.32)', borderColor: 'rgba(95, 86, 76, 0.45)', borderWidth: 1 },
                { label: 'Current Tax System', data: [], backgroundColor: 'rgba(179, 74, 60, 0.82)' },
                { label: 'New AI / Capital Tax', data: [], backgroundColor: 'rgba(60, 141, 64, 0.82)' }
            ]
        },
        options: {
            responsive: true,
            maintainAspectRatio: false,
            scales: {
                y: {
                    beginAtZero: true,
                    max: 100,
                    grid: commonScales.yGrid,
                    ticks: commonScales.tick,
                    title: { display: true, text: 'Wealth Share (%)', color: commonScales.title.color }
                },
                x: {
                    grid: { display: false },
                    ticks: commonScales.tick
                }
            },
            plugins: {
                legend: { display: false },
                tooltip: { callbacks: { label: (c) => `${c.dataset.label}: ${c.raw.toFixed(1)}%` } }
            }
        }
    });
}

function calcShares(data) {
    if (!data || data.length === 0) return { top1: 0, top10: 0, bottom50: 0 };
    const sorted = Float32Array.from(data).sort();
    const totalWealth = sorted.reduce((a, b) => a + b, 0);
    if (totalWealth <= 0) return { top1: 0, top10: 0, bottom50: 0 };

    const n = sorted.length;
    const top1Count = Math.ceil(n * 0.01);
    const top10Count = Math.ceil(n * 0.10);
    const bottom50Count = Math.floor(n * 0.50);

    let top1Sum = 0;
    let top10Sum = 0;
    let bottom50Sum = 0;

    for (let i = n - top1Count; i < n; i++) top1Sum += sorted[i];
    for (let i = n - top10Count; i < n; i++) top10Sum += sorted[i];
    for (let i = 0; i < bottom50Count; i++) bottom50Sum += sorted[i];

    return {
        top1: (top1Sum / totalWealth) * 100,
        top10: (top10Sum / totalWealth) * 100,
        bottom50: (bottom50Sum / totalWealth) * 100
    };
}

function updateSummaryCards(yearIndex) {
    if (!globalResults) return;
    const pointIndex = Math.max(0, Math.min(yearIndex, globalResults.gini.a.length - 1));
    const sharesAtYear = {
        a: calcShares(globalResults.distribution.a[pointIndex]),
        b: calcShares(globalResults.distribution.b[pointIndex])
    };
    const revenueA = globalResults.revenue.a[pointIndex] ?? 0;
    const revenueB = globalResults.revenue.b[pointIndex] ?? 0;
    const ubiA = globalResults.ubi.a[pointIndex] ?? 0;
    const ubiB = globalResults.ubi.b[pointIndex] ?? 0;

    const top1Reduction = sharesAtYear.a.top1 - sharesAtYear.b.top1;
    const bottom50Gain = sharesAtYear.b.bottom50 - sharesAtYear.a.bottom50;
    const fundingGap = revenueB > 0 ? ((revenueB - revenueA) / revenueB) * 100 : 0;
    displays.fundingGap.textContent = `${fundingGap >= 0 ? '' : '-'}${Math.abs(fundingGap).toFixed(0)}%`;
    displays.ubiGap.textContent = `${currencyFormatter.format(ubiA)} -> ${currencyFormatter.format(ubiB)}`;
    displays.winnerTop1.textContent = `${top1Reduction >= 0 ? '+' : '-'}${Math.abs(top1Reduction).toFixed(1)} pts`;
    displays.winnerEmployment.textContent = `${bottom50Gain >= 0 ? '+' : '-'}${Math.abs(bottom50Gain).toFixed(1)} pts`;

}

function updateDistributionChart(year) {
    if (!globalResults) return;

    const dataA = globalResults.distribution.a[year];
    const dataB = globalResults.distribution.b[year];
    const allData = [...dataA, ...dataB].sort((a, b) => a - b);
    const p98 = allData[Math.floor(allData.length * 0.98)];
    const maxW = Math.max(p98 * 1.2, 100000);
    const minW = Math.max(0, allData[0]);
    const bw = Math.max((maxW - minW) / 16, 1);

    const kdeA = computeKDE(dataA, bw, [minW, maxW]);
    const kdeB = computeKDE(dataB, bw, [minW, maxW]);

    distChart.data.labels = kdeA.x;
    distChart.data.datasets[0].data = kdeA.y;
    distChart.data.datasets[1].data = kdeB.y;
    distChart.update('none');

    displays.timeline.textContent = `Year ${year}`;
    document.getElementById('share-year-val').textContent = String(year);

    const sharesA = calcShares(dataA);
    const sharesB = calcShares(dataB);
    const sharesStart = calcShares(globalResults.distribution.a[0]);

    shareChart.data.datasets[0].data = [sharesStart.top1, sharesStart.top10, sharesStart.bottom50];
    shareChart.data.datasets[1].data = [sharesA.top1, sharesA.top10, sharesA.bottom50];
    shareChart.data.datasets[2].data = [sharesB.top1, sharesB.top10, sharesB.bottom50];
    shareChart.update('none');
    updateSummaryCards(year);
}

function runSimulation() {
    const distParams = inputs.distType.value === 'pareto'
        ? { shape: parseFloat(inputs.shape.value), scale: 20000 }
        : { mean: parseFloat(inputs.mean.value), sigma: parseFloat(inputs.sigma.value) };

    const params = {
        n_people: 4000,
        n_years: parseInt(inputs.years.value, 10),
        distribution_type: inputs.distType.value,
        dist_params: distParams,
        initial_wealth_multiplier: parseFloat(inputs.multiplier.value),
        net_wealth_yield: parseFloat(inputs.yield.value) / 100,
        tax_capital_gains: inputs.taxCap.checked,
        policyA: {
            type: 'us2024',
            rate: 0,
            deduction: 0,
            brackets: activeBrackets.A,
            ubi_pct: parseFloat(inputs.ubiA.value) / 100
        },
        policyB: {
            type: 'custom-progressive',
            rate: 0,
            deduction: parseInt(inputs.deductionB.value, 10),
            brackets: activeBrackets.B,
            ubi_pct: parseFloat(inputs.ubiB.value) / 100,
            capital_tax_rate: parseFloat(inputs.capitalTaxB.value) / 100
        },
        labor_displacement: parseFloat(inputs.displacement.value) / 100,
        capital_efficiency_boost: parseFloat(inputs.efficiency.value) / 100
    };

    displays.years.textContent = String(params.n_years);
    displays.yield.textContent = `${(params.net_wealth_yield * 100).toFixed(1)}%`;
    displays.multiplier.textContent = `${params.initial_wealth_multiplier.toFixed(1)}x`;
    displays.mean.textContent = currencyFormatter.format(params.dist_params.mean ?? 0);
    displays.sigma.textContent = inputs.sigma.value;
    displays.shape.textContent = inputs.shape.value;
    displays.deductionB.textContent = currencyFormatter.format(Number(inputs.deductionB.value));
    displays.ubiA.textContent = `${inputs.ubiA.value}%`;
    displays.ubiB.textContent = `${inputs.ubiB.value}%`;
    displays.capitalTaxB.textContent = `${inputs.capitalTaxB.value}%`;
    displays.displacement.textContent = `${Number(inputs.displacement.value).toFixed(1)}%`;
    displays.efficiency.textContent = `${Number(inputs.efficiency.value).toFixed(1)}%`;

    inputs.timeline.max = String(params.n_years);
    if (parseInt(inputs.timeline.value, 10) > params.n_years) inputs.timeline.value = String(params.n_years);

    const sim = new MultiYearSimulation(params);
    globalResults = sim.run();

    const currentViewYear = parseInt(inputs.timeline.value, 10);
    updateDistributionChart(currentViewYear);

    giniChart.data.labels = Array.from({ length: params.n_years + 1 }, (_, i) => i);
    giniChart.data.datasets[0].data = smoothSeries(globalResults.gini.a, 2);
    giniChart.data.datasets[1].data = smoothSeries(globalResults.gini.b, 2);
    giniChart.update('none');

    writeScenarioToUrl();
}

function stopTimelinePlayback() {
    if (timelineTimer) {
        window.clearInterval(timelineTimer);
        timelineTimer = null;
    }
    playButton.textContent = 'Play';
}

function toggleTimelinePlayback() {
    if (timelineTimer) {
        stopTimelinePlayback();
        return;
    }

    playButton.textContent = 'Pause';
    timelineTimer = window.setInterval(() => {
        const current = Number(inputs.timeline.value);
        const max = Number(inputs.timeline.max);
        if (current >= max) {
            stopTimelinePlayback();
            return;
        }
        inputs.timeline.value = String(current + 1);
        updateDistributionChart(current + 1);
        writeScenarioToUrl();
    }, 180);
}

async function copyScenarioLink() {
    writeScenarioToUrl();
    const url = window.location.href;
    try {
        await navigator.clipboard.writeText(url);
        shareButton.textContent = 'Link Copied';
        window.setTimeout(() => {
            shareButton.textContent = 'Copy Scenario Link';
        }, 1400);
    } catch {
        shareButton.textContent = 'Copy Failed';
        window.setTimeout(() => {
            shareButton.textContent = 'Copy Scenario Link';
        }, 1400);
    }
}

function attachEvents() {
    const simulationInputs = [
        inputs.years, inputs.yield, inputs.taxCap, inputs.multiplier, inputs.distType, inputs.mean, inputs.sigma, inputs.shape,
        inputs.ubiA, inputs.deductionB, inputs.ubiB, inputs.capitalTaxB,
        inputs.displacement, inputs.efficiency
    ];

    simulationInputs.forEach((input) => {
        input.addEventListener('input', () => {
            updateUI();
            runSimulation();
        });
    });

    inputs.timeline.addEventListener('input', () => {
        updateDistributionChart(parseInt(inputs.timeline.value, 10));
        writeScenarioToUrl();
    });

    resetButton.addEventListener('click', loadDefaults);
    shareButton.addEventListener('click', copyScenarioLink);
    playButton.addEventListener('click', toggleTimelinePlayback);
    startButton.addEventListener('click', () => {
        stopTimelinePlayback();
        inputs.timeline.value = '0';
        updateDistributionChart(0);
        writeScenarioToUrl();
    });
    endButton.addEventListener('click', () => {
        stopTimelinePlayback();
        const max = Number(inputs.timeline.max);
        inputs.timeline.value = String(max);
        updateDistributionChart(max);
        writeScenarioToUrl();
    });

    document.querySelectorAll('[data-help]').forEach((button) => {
        button.addEventListener('click', (event) => {
            event.preventDefault();
            event.stopPropagation();
            openHelp(button.dataset.help);
        });
    });

    helpModal.addEventListener('click', (event) => {
        if (event.target === helpModal) helpModal.style.display = 'none';
    });

    modal.addEventListener('click', (event) => {
        if (event.target === modal) modal.style.display = 'none';
    });
}

initCharts();
attachEvents();
updateUI();

const loadedScenario = parseScenarioFromUrl();
if (loadedScenario) {
    applyState({ ...defaults, ...loadedScenario.state });
} else {
    loadDefaults();
}