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">×</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();
}