Skip to content

Instantly share code, notes, and snippets.

@twobob
Last active May 10, 2025 11:52
Show Gist options
  • Save twobob/14851955b98941618af53500f3ce5944 to your computer and use it in GitHub Desktop.
Save twobob/14851955b98941618af53500f3ce5944 to your computer and use it in GitHub Desktop.
IOUBI alpha sim
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IOUBItopia Weaver</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
background-color: #1a1a2e;
color: #e0e0fc;
line-height: 1.6;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
}
.container {
width: 90%;
max-width: 1600px;
margin: 20px auto;
background-color: #162447;
border-radius: 12px;
box-shadow: 0 8px 25px rgba(0,0,0,0.3);
padding: 20px;
position: relative;
}
#total-sim-time-display,
#total-year-time-display,
#step-time-display {
position: fixed;
right: 10px;
background-color: rgba(0,0,0,0.7);
color: #fca311;
padding: 5px 10px;
border-radius: 5px;
font-size: 0.85em;
z-index: 1000;
}
#total-sim-time-display { top: 10px; }
#total-year-time-display { top: 35px; } /* Position below Total Sim Time */
#step-time-display { top: 60px; } /* Position below Total Year Time */
header {
text-align: center;
margin-bottom: 25px;
padding-bottom: 15px;
border-bottom: 1px solid #243b55;
}
header h1 {
margin: 0;
font-size: 2.8em;
color: #fca311;
text-shadow: 0 0 10px #fca311;
}
header p {
font-size: 1.1em;
color: #a7a9be;
}
.game-area {
display: flex;
gap: 25px;
}
.controls-panel {
flex: 1;
background-color: #1f4068;
padding: 20px;
border-radius: 8px;
box-shadow: inset 0 0 10px rgba(0,0,0,0.2);
height: fit-content;
}
.controls-panel h2, .controls-panel h3 {
color: #e4bb97;
margin-top: 0;
border-bottom: 1px solid #2e5077;
padding-bottom: 10px;
}
#next-year-btn {
background: linear-gradient(145deg, #fca311, #e4bb97);
color: #162447;
border: none;
padding: 12px 25px;
font-size: 1.1em;
font-weight: bold;
border-radius: 50px;
cursor: pointer;
transition: all 0.3s ease;
width: 100%;
margin-bottom: 20px;
box-shadow: 0 4px 15px rgba(252, 163, 17, 0.4);
}
#next-year-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(252, 163, 17, 0.6);
}
#next-year-btn:disabled {
background: #555;
color: #888;
cursor: not-allowed;
box-shadow: none;
}
.parameter {
margin-bottom: 18px;
}
.parameter label {
display: block;
margin-bottom: 6px;
font-weight: 600;
font-size: 0.95em;
color: #c5c6d0;
}
.parameter input[type="number"], .parameter input[type="range"], .parameter select {
width: calc(100% - 24px);
padding: 10px 12px;
border-radius: 6px;
border: 1px solid #2e5077;
background-color: #1b2a49;
color: #e0e0fc;
font-size: 0.9em;
box-sizing: border-box;
}
.parameter input[type="range"] {
padding: 0;
}
.parameter .value-display {
font-size: 0.85em;
color: #a7a9be;
margin-left: 10px;
}
.dashboard {
flex: 3;
display: flex;
flex-direction: column;
gap: 25px;
}
.dashboard-module {
background-color: #1f4068;
border-radius: 8px;
box-shadow: inset 0 0 10px rgba(0,0,0,0.2);
}
.module-title-bar {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #1f4068; /* Slightly different from module content */
padding: 10px 15px;
border-bottom: 1px solid #2e5077;
cursor: move; /* For dragging */
border-radius: 8px 8px 0 0; /* Rounded top corners */
}
.module-title-bar h3 {
margin: 0;
font-size: 1.1em;
color: #e0e0fc;
flex-grow: 0; /* Changed from 1 to 0 to allow steptime to sit next to title */
}
.module-steptime {
font-size: 0.75em;
color: #D870A9; /* Pinkish color */
margin-left: 10px;
font-weight: normal;
}
.module-controls {
display: flex;
gap: 10px;
}
.module-title-bar .module-controls {
margin-left: auto; /* Ensures controls are pushed to the far right */
}
.module-controls .icon {
cursor: pointer;
width: 20px;
height: 20px;
fill: #a7a9be;
transition: fill 0.2s ease;
}
.module-controls .icon:hover {
fill: #e0e0fc;
}
.module-content {
padding: 20px;
transition: max-height 0.3s ease-out, opacity 0.2s ease-out 0.1s, padding 0.3s ease-out;
max-height: 2000px;
opacity: 1;
visibility: visible; /* Ensure visible when not collapsed */
}
.dashboard-module.collapsed .module-content {
max-height: 0;
opacity: 0;
padding-top: 0;
padding-bottom: 0;
border-top: none;
visibility: hidden; /* Hide when collapsed */
/* Delay visibility change until after other transitions */
transition: max-height 0.3s ease-out, opacity 0.2s ease-out, padding 0.3s ease-out, visibility 0s linear 0.3s;
}
.dashboard-module.collapsed .module-title-bar {
border-bottom-color: transparent;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 18px;
}
.metric-card {
background-color: #1b2a49;
padding: 18px;
border-radius: 8px;
text-align: center;
border: 1px solid #2e5077;
position: relative;
transition: transform 0.2s ease-in-out;
}
.metric-card:hover {
transform: translateY(-3px);
}
.metric-card h4 {
margin: 0 0 8px 0;
font-size: 1em;
color: #c5c6d0;
}
.metric-card p {
margin: 0;
font-size: 1.8em;
font-weight: 700;
color: #fca311;
}
.metric-card .unit {
font-size: 0.6em;
font-weight: normal;
color: #a7a9be;
}
.metric-card[data-tooltip]::after,
.agent-cohorts-panel td[title]::after,
.agent-cohorts-panel th[title]::after {
/* Remove position: absolute; */
position: fixed;
left: 0; top: 0;
background-color: #111;
color: #fff;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.85em;
white-space: pre-wrap;
z-index: 99999 !important; /* Ensure always on top */
width: max-content;
max-width: 350px;
text-align: left;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.2s, visibility 0.2s;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
}
.metric-card[data-tooltip]:hover::after,
.agent-cohorts-panel td[title]:hover::after,
.agent-cohorts-panel th[title]:hover::after {
opacity: 1;
visibility: visible;
}
/* Hide default browser tooltips for th/td */
.agent-cohorts-panel th[title],
.agent-cohorts-panel td[title] {
position: relative;
}
#main-chart, #lorenz-chart, #volume-chart {
max-height: 350px;
width: 100%;
}
.chart-container {
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.chart-container > div {
flex: 1 1 45%;
min-width: 300px;
display: flex;
flex-direction: column;
align-items: center;
}
.chart-container > div > h4 {
width: 100%;
text-align: center;
margin-bottom: 10px;
color: #e4bb97;
}
#event-log, #sampled-transaction-log {
max-height: 180px;
overflow-y: auto;
border: 1px solid #2e5077;
padding: 12px;
font-size: 0.9em;
background-color: #162447;
border-radius: 6px;
}
#event-log p, #sampled-transaction-log p {
margin: 0 0 7px 0;
padding-bottom: 7px;
border-bottom: 1px dashed #243b55;
color: #c5c6d0;
}
#event-log p:last-child, #sampled-transaction-log p:last-child {
border-bottom: none;
}
#event-log .info, #sampled-transaction-log .info { color: #87CEEB; }
#event-log .warning, #sampled-transaction-log .warning { color: #FFD700; }
#event-log .critical, #sampled-transaction-log .critical { color: #FF6347; }
#event-log .positive, #sampled-transaction-log .positive { color: #90EE90; }
.agent-cohorts-panel table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
min-width: 700px;
}
.agent-cohorts-table-container {
width: 100%;
overflow-x: auto;
margin-top: 10px;
}
.agent-cohorts-panel th, .agent-cohorts-panel td {
border: 1px solid #2e5077;
padding: 8px;
text-align: left;
font-size: 0.8em;
}
.agent-cohorts-panel th {
background-color: #1b2a49;
color: #e4bb97;
}
.agent-cohorts-panel td {
color: #c5c6d0;
}
footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #243b55;
font-size: 0.9em;
color: #777;
}
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: #162447; border-radius: 10px; }
::-webkit-scrollbar-thumb { background: #2e5077; border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: #1f4068; }
/* Dragging placeholder style - changed to a line */
.dragging-placeholder {
background-color: #fca311; /* Accent color for the line */
height: 2px; /* Thin line */
margin: 12px 0; /* Space around the line, half of the gap */
border-radius: 1px;
box-sizing: border-box;
}
.dashboard-module.is-dragging {
opacity: 0.6; /* Make dragged item semi-transparent */
border: 2px dashed #fca311; /* Accent border */
box-shadow: 0 5px 15px rgba(0,0,0,0.2); /* Lift effect */
}
/* Styles for the individual stats module's controls and chart container */
.individual-stats-controls {
display: flex;
flex-wrap: wrap; /* Allow wrapping on smaller screens */
gap: 15px;
margin-bottom: 15px;
align-items: center;
}
.individual-stats-controls label {
margin-right: 5px;
font-weight: normal;
color: #c5c6d0;
font-size: 0.9em;
}
.individual-stats-controls select {
padding: 8px 10px;
border-radius: 6px;
border: 1px solid #2e5077;
background-color: #1b2a49;
color: #e0e0fc;
font-size: 0.9em;
}
#individualStatsChartContainer {
margin-top: 10px;
width: 100%;
height: 300px; /* Adjust as needed, or use aspect-ratio ?? dunno */
position: relative; /* Important for Chart.js responsiveness */
}
@media (max-width: 1200px) {
.container {
width: 95%;
}
.game-area {
flex-direction: column;
}
.controls-panel, .dashboard {
width: 100%;
box-sizing: border-box;
}
.metric-grid {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
}
@media (max-width: 768px) {
header h1 { font-size: 2.0em; }
.metric-card p { font-size: 1.6em; }
.agent-cohorts-panel th, .agent-cohorts-panel td {
font-size: 0.75em;
padding: 5px;
}
.game-area {
gap: 15px;
}
.controls-panel, .dashboard {
padding: 15px;
}
.metrics-overview, .trends-chart, .log-panel, .agent-cohorts-panel, .additional-charts-panel, .transaction-log-panel {
padding: 15px;
}
}
</style>
</head>
<body>
<div class="container">
<div id="total-sim-time-display">Total Sim Time: 0.0s</div>
<div id="total-year-time-display">Total Year Time: 0.0s</div>
<div id="step-time-display">Step Time: 0ms</div>
<header>
<h1>IOUBItopia Weaver</h1>
<p>Simulate and Shape an IOUBI-Powered Future</p>
</header>
<div class="game-area">
<aside class="controls-panel">
<h2>Controls</h2>
<div class="parameter">
<label for="current-year" style="float: left;">Current Year:</label>&nbsp;
<span id="current-year">0</span> / <span id="max-years">100</span>
</div>
<div class="parameter">
<label for="current-day" style="float: left;">Current Day:</label>&nbsp;
<span id="current-day">0</span> / <span id="days-per-year">365</span>
</div>
<button id="next-year-btn">Advance to Next Year</button>
<button id="run-tests-btn" style="margin-top: 15px;">Run Unit Tests</button>
<h3>Simulation Parameters</h3>
<div class="parameter">
<label for="initial-personal-credit">Initial Personal Credit (Δ):</label>
<input type="number" id="initial-personal-credit" value="1000" min="100" step="100">
</div>
<div class="parameter">
<label for="initial-business-credit">Initial Business Credit (Δ):</label>
<input type="number" id="initial-business-credit" value="10000" min="1000" step="1000">
</div>
<div class="parameter">
<label for="base-adoption-rate">Base Adoption Rate (%/year):</label>
<input type="range" id="base-adoption-rate" value="0.5" min="0.01" max="5" step="0.01">
<span class="value-display" id="base-adoption-rate-value">0.5%</span>
</div>
<div class="parameter">
<label for="transaction-fee-split-ubi">Transaction Fee to UBI Pool (%):</label>
<input type="range" id="transaction-fee-split-ubi" value="75" min="0" max="95" step="1">
<span class="value-display" id="transaction-fee-split-ubi-value">75%</span>
</div>
<div class="parameter">
<label for="grant-focus">Grant Focus:</label>
<select id="grant-focus">
<option value="balanced" selected>Balanced</option>
<option value="technology">Technology</option>
<option value="social_welfare">Social Welfare</option>
<option value="environment">Environment</option>
<option value="infrastructure">Infrastructure</option>
<option value="social_projects">Social Projects</option>
</select>
</div>
<div class="parameter">
<label for="avg-velocity-multiplier">Avg. Velocity Multiplier:</label>
<input type="range" id="avg-velocity-multiplier" value="0.2" min="0" max="1" step="0.01">
<span class="value-display" id="avg-velocity-multiplier-value">0.20</span>
</div>
<div class="parameter">
<label for="red-money-tolerance">Red Money Tolerance Factor:</label>
<input type="range" id="red-money-tolerance" value="0.2" min="0" max="1" step="0.01">
<span class="value-display" id="red-money-tolerance-value">0.20</span>
</div>
<div class="parameter">
<label for="innovation-investment-propensity">Innovation Investment Propensity:</label>
<input type="range" id="innovation-investment-propensity" value="0.1" min="0" max="1" step="0.1">
<span class="value-display" id="innovation-investment-propensity-value">0.10</span>
</div>
<div class="parameter">
<label for="days-in-year">Days in Year:</label>
<input type="number" id="days-in-year" value="365" min="12" max="365" step="1">
</div>
<div class="parameter">
<label for="yearly-time-budget">Yearly Sim Time Budget (s):</label>
<input type="range" id="yearly-time-budget" value="5" min="5" max="60" step="1">
<span class="value-display" id="yearly-time-budget-value">5s</span>
</div>
<div class="parameter">
<label for="ui-update-frequency">UI Update Frequency:</label>
<select id="ui-update-frequency">
<option value="daily">Daily</option>
<option value="monthly" selected>Monthly</option>
<option value="yearly">Yearly</option>
</select>
</div>
</aside>
<main class="dashboard" id="dashboard-main">
<div class="dashboard-module" id="module-metrics">
<div class="module-title-bar" draggable="true">
<h3>Key Global Metrics</h3>
<div class="module-controls">
<svg class="icon toggle-collapse" viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg>
</div>
</div>
<div class="module-content">
<section class="metrics-overview">
<div class="metric-grid">
<div class="metric-card" data-tooltip="Latest daily income per individual from the global pool.&#10;Calculated as: (75% of yesterday's total transaction fees) / current IOUBI users.&#10;&#10;Note: In IOUBI, fees do not accumulate in a pool. They vanish, and UBI is distributed later based on tracked vanished fees."><h4>Latest Daily UBI</h4><p><span id="avg-ubi">0.00</span> <span class="unit">Δ</span></p></div>
<div class="metric-card" data-tooltip="Gini Coefficient: Measures wealth inequality.&#10;Calculated as: sum of absolute differences of all pairs of cohort balances, weighted by population, divided by twice the mean balance.&#10;0 = perfect equality, 1 = max inequality."><h4>Gini Coefficient</h4><p><span id="gini-coefficient">0.80</span></p></div>
<div class="metric-card" data-tooltip="Relative Poverty Rate: % of population below 60% of average well-being.&#10;Calculated as: (population with well-being < 0.6 × avg well-being) / total IOUBI users."><h4>Relative Poverty Rate</h4><p><span id="poverty-rate">75.0</span> <span class="unit">%</span></p></div>
<div class="metric-card" data-tooltip="Tech Level: Proxy for overall technological capability.&#10;Increases from R&D investments (from innovation propensity and grants) and a small base increase per year.&#10;Calculated as: previous tech level + R&D effect + grant effect + base increase."><h4>Tech Level</h4><p><span id="tech-level">1.0</span></p></div>
<div class="metric-card" data-tooltip="IOUBI Adoption: % and actual user count.&#10;Calculated as: IOUBI users / global population."><h4>IOUBI Adoption</h4><p><span id="ioubi-adoption">0.1</span> <span class="unit">%</span> <span id="ioubi-adoption-abs" style="font-size:0.7em;color:#a7a9be;"></span></p></div>
<div class="metric-card" data-tooltip="Total value of overdrafts in the system (all used credit).&#10;Sum of all negative balances × population for each cohort and business."><h4>Total Red Money</h4><p><span id="total-red-money">0</span> <span class="unit">Δ</span></p></div>
<div class="metric-card" data-tooltip="Total value of positive balances in the system (savings/hoardings).&#10;Sum of all positive balances × population for each cohort and business."><h4>Total Positive Balances</h4><p><span id="total-positive-balances">0</span> <span class="unit">Δ</span></p></div>
<div class="metric-card" data-tooltip="Net worth of the system (Positive - Red).&#10;Should be near zero in a closed system."><h4>Net System Deltars</h4><p><span id="net-system-deltars">0</span> <span class="unit">Δ</span></p></div>
<div class="metric-card" data-tooltip="Annual Pool Revenue: Sum of all transaction fees collected this year.&#10;Daily Pool Revenue: Fees from the last simulated day.&#10;Fee per transaction is based on log10 of balances involved.&#10;&#10;Note: In IOUBI, these fees vanish from the system and are not stored in a pool. UBI is distributed later based on tracked vanished fees."><h4>Pool Revenue</h4><p><span id="annual-pool-revenue">0</span> <span class="unit">Δ</span><br><span id="daily-pool-revenue" style="font-size:0.7em;color:#a7a9be;"></span></p></div>
<div class="metric-card" data-tooltip="Credit Utilization: Total red money as % of all credit limits.&#10;Calculated as: (Total Red Money / Total Credit Limit) × 100."><h4>Credit Utilization</h4><p><span id="credit-utilization">0.0</span> <span class="unit">%</span></p></div>
<div class="metric-card" data-tooltip="Total social credit currently borrowed by cohorts.&#10;Sum of socialCreditBorrowed for all cohorts."><h4>Social Credit Debt</h4><p><span id="social-credit-lent">0</span> <span class="unit">Δ</span></p></div>
<div class="metric-card" data-tooltip="Number of orgs using social credit (active social projects).&#10;Count of group accounts with positive balance or started in last 5 years."><h4>Active Social Projects</h4><p><span id="active-social-projects">0</span></p></div>
</div>
</section>
</div>
</div>
<div class="dashboard-module" id="module-cohorts">
<div class="module-title-bar" draggable="true">
<h3>Agent Cohort Overview</h3>
<div class="module-controls">
<svg class="icon toggle-collapse" viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg>
</div>
</div>
<div class="module-content">
<section class="agent-cohorts-panel">
<div class="agent-cohorts-table-container">
<table>
<thead>
<tr>
<th title="Cohort name. Represents a socioeconomic group.">Cohort</th>
<th title="Population share of this cohort as a % of all IOUBI users.">Pop (%)</th>
<th title="Average balance per person in this cohort. Positive = savings, Negative = debt.">Avg. Balance (Δ)</th>
<th title="Average credit limit per person in this cohort.">Avg. Credit Limit (Δ)</th>
<th title="Well-being: Composite score (0-1) based on UBI, balance, credit, tech level, and inequality. Formula: 0.1 + (annual UBI / (0.5 × initial credit)) × 0.2 + (balance / (credit limit + |balance| + 1000)) × 0.5 + (tech level / 20) × 0.2 + (1 - gini) × 0.1">Well-being</th>
<th title="Social Credit: Amount available to lend / amount borrowed. Calculated as 0.2 × avg credit limit for each cohort.">Social Credit (Avail./Borrowed Δ)</th>
</tr>
</thead>
<tbody id="agent-cohorts-table-body">
</tbody>
</table>
</div>
</section>
</div>
</div>
<div class="dashboard-module" id="module-main-trends">
<div class="module-title-bar" draggable="true">
<h3>Global Trends Over Time (Annual Summary)</h3>
<div class="module-controls">
<svg class="icon toggle-collapse" viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg>
</div>
</div>
<div class="module-content">
<section class="trends-chart">
<canvas id="main-chart"></canvas>
</section>
</div>
</div>
<div class="dashboard-module" id="module-additional-charts">
<div class="module-title-bar" draggable="true">
<h3>Additional Economic Visualizations</h3>
<div class="module-controls">
<svg class="icon toggle-collapse" viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg>
</div>
</div>
<div class="module-content">
<section class="additional-charts-panel">
<div class="chart-container">
<div>
<h4>Wealth Distribution (Lorenz Curve Approx. - Current)</h4>
<canvas id="lorenz-chart"></canvas>
</div>
<div>
<h4>Daily Transaction Volume & Pool Revenue (Current Year)</h4>
<canvas id="volume-chart"></canvas>
</div>
</div>
</section>
</div>
</div>
<div class="dashboard-module" id="module-tx-log">
<div class="module-title-bar" draggable="true">
<h3>Sampled Transaction Log (Last 10)</h3>
<div class="module-controls">
<svg class="icon toggle-collapse" viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg>
</div>
</div>
<div class="module-content">
<section class="transaction-log-panel">
<div id="sampled-transaction-log"><p>No transactions logged yet.</p></div>
</section>
</div>
</div>
<div class="dashboard-module" id="module-event-log">
<div class="module-title-bar" draggable="true">
<h3>Event Log</h3>
<div class="module-controls">
<svg class="icon toggle-collapse" viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg>
</div>
</div>
<div class="module-content">
<section class="log-panel">
<div id="event-log"><p>Year 0 Day 0: IOUBI Initiative Launched. Set initial parameters to begin.</p></div>
</section>
</div>
</div>
<!-- Add a "Sanity Check" panel above the metrics for clarity -->
<div class="dashboard-module" id="module-sanity-check">
<div class="module-title-bar" draggable="true">
<h3>Sanity Check: System Totals</h3>
<div class="module-controls">
<svg class="icon toggle-collapse" viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg>
</div>
</div>
<div class="module-content">
<section class="metrics-overview">
<div class="metric-grid">
<div class="metric-card" data-tooltip="Total IOUBI Population (sum of all cohorts)."><h4>Population</h4><p><span id="sanity-population">0</span></p></div>
<div class="metric-card" data-tooltip="Weighted average balance across all cohorts and businesses. Should be near zero except for money in pools."><h4>Total Avg. Balance</h4><p><span id="sanity-avg-balance">0</span> <span class="unit">Δ</span></p></div>
<div class="metric-card" data-tooltip="Sum of all positive balances (should match sum of all negative balances in a closed system, except for pools)."><h4>Total Positive</h4><p><span id="sanity-total-positive">0</span> <span class="unit">Δ</span></p></div>
<div class="metric-card" data-tooltip="Sum of all negative balances (should match sum of all positive balances in a closed system, except for pools)."><h4>Total Negative</h4><p><span id="sanity-total-negative">0</span> <span class="unit">Δ</span></p></div>
<div class="metric-card" data-tooltip="Sum of all balances (should be near zero except for money in pools).&#10;&#10;Note: In IOUBI, transaction fees vanish from the system and are not stored in a pool. UBI is distributed later based on tracked vanished fees. This can cause a temporary mismatch in total balances."><h4>Net System Balance</h4><p><span id="sanity-net-balance">0</span> <span class="unit">Δ</span></p></div>
<div class="metric-card" data-tooltip="Shows the difference between total vanished fees (for UBI) and the UBI actually distributed so far.&#10;A positive value means some UBI is pending distribution (accounts not yet refreshed).&#10;A negative value means more UBI was distributed than fees vanished (should be rare).&#10;&#10;This reflects the IOUBI model: fees vanish, UBI is distributed later, so the system can be temporarily out of balance."><h4>UBI Distribution Status</h4><p><span id="sanity-ubi-status">0</span> <span class="unit">Δ</span></p></div>
</div>
</section>
</div>
</div>
<div class="dashboard-module" id="module-individual-stats">
<div class="module-title-bar" draggable="true">
<h3>Individual/Cohort Statistics</h3>
<div class="module-controls">
<svg class="icon toggle-collapse" viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg>
</div>
</div>
<div class="module-content">
<div class="individual-stats-controls">
<label for="individualCohortSelect">Cohort/Individual:</label>
<select id="individualCohortSelect">
</select>
<label for="individualTimeframeSelect">Timeframe:</label>
<select id="individualTimeframeSelect">
</select>
<label for="individualLogRetentionSelect">Log Retention:</label>
<select id="individualLogRetentionSelect">
<option value="1_year">1 Year</option>
<option value="2_years" selected>2 Years</option>
<option value="5_years">5 Years</option>
<option value="10_years">10 Years</option>
<option value="20_years">20 Years</option>
<option value="all_time">All Time</option>
</select>
</div>
<div id="individualStatsChartContainer">
<canvas id="individualStatsChart"></canvas>
</div>
</div>
</div>
</main>
</div>
<footer>
<p>IOUBI Utopia Weaver - OneFile Edition (Interactive UI). Simulation is an abstraction.</p>
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
function enableFloatingTooltips() {
const tooltipDiv = document.createElement('div');
tooltipDiv.className = 'floating-tooltip';
document.body.appendChild(tooltipDiv);
// Apply proper tooltip styling
Object.assign(tooltipDiv.style, {
position: 'fixed',
display: 'none',
zIndex: '99999',
background: '#111',
color: '#fff',
padding: '8px 12px',
borderRadius: '6px',
fontSize: '0.85em',
whiteSpace: 'pre-wrap',
maxWidth: '350px',
textAlign: 'left',
pointerEvents: 'none',
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
border: '1px solid #2e5077'
});
// Handle tooltip events for data-tooltip elements
document.querySelectorAll('.metric-card[data-tooltip], .agent-cohorts-panel th[title], .agent-cohorts-panel td[title]').forEach(el => {
const tooltipText = el.getAttribute('data-tooltip') || el.getAttribute('title');
el.addEventListener('mouseenter', () => {
tooltipDiv.textContent = tooltipText;
tooltipDiv.style.display = 'block';
});
el.addEventListener('mousemove', e => {
// Position tooltip so it's not cut off at edges
const rect = tooltipDiv.getBoundingClientRect();
let x = e.clientX + 15;
let y = e.clientY - rect.height - 10;
// Keep tooltip on screen
if (x + rect.width > window.innerWidth) {
x = window.innerWidth - rect.width - 10;
}
if (y < 10) {
y = e.clientY + 15; // Show below if too close to top
}
tooltipDiv.style.left = x + 'px';
tooltipDiv.style.top = y + 'px';
});
el.addEventListener('mouseleave', () => {
tooltipDiv.style.display = 'none';
});
});
}
document.addEventListener('DOMContentLoaded', () => {
// --- DOM Elements ---
const currentYearEl = document.getElementById('current-year');
const currentDayEl = document.getElementById('current-day');
const daysPerYearDisplayEl = document.getElementById('days-per-year');
const maxYearsEl = document.getElementById('max-years');
const nextYearBtn = document.getElementById('next-year-btn');
const runTestsBtn = document.getElementById('run-tests-btn'); // Get the new button
const eventLogEl = document.getElementById('event-log');
const sampledTransactionEl = document.getElementById('sampled-transaction-log');
const agentCohortsTableBodyEl = document.getElementById('agent-cohorts-table-body');
const dashboardMainEl = document.getElementById('dashboard-main');
// --- DOM Elements for Individual Stats ---
const individualCohortSelectEl = document.getElementById('individualCohortSelect');
const individualTimeframeSelectEl = document.getElementById('individualTimeframeSelect');
const individualStatsChartCanvasEl = document.getElementById('individualStatsChart');
const individualLogRetentionSelectEl = document.getElementById('individualLogRetentionSelect');
// --- Utility Functions ---
function formatNumber(number, decimals = 0) {
if (isNaN(number) || !isFinite(number)) return (typeof number === 'number' && number.toString()) || "N/A";
if (number === 0) return "0";
const absNumber = Math.abs(number);
const sign = number < 0 ? "-" : "";
if (absNumber < 1000) return sign + absNumber.toFixed(decimals);
const suffixes = ["", "K", "M", "B", "T", "P", "E", "Z", "Y"];
let suffixIndex = Math.floor(Math.log10(absNumber) / 3);
suffixIndex = Math.min(suffixIndex, suffixes.length -1);
const scaledNumber = absNumber / Math.pow(10, 3 * suffixIndex);
return sign + scaledNumber.toFixed(decimals) + suffixes[suffixIndex];
}
function updateModuleSteptime(moduleId, startTimeMs) {
const endTimeMs = performance.now();
const durationMs = Math.round(endTimeMs - startTimeMs);
const moduleEl = document.getElementById(moduleId);
if (moduleEl) {
const steptimeEl = moduleEl.querySelector('.module-steptime');
if (steptimeEl) {
steptimeEl.textContent = `(${durationMs} ms)`;
}
}
}
function safeDiv(numerator, denominator, defaultVal = 0) {
if (isNaN(numerator) || isNaN(denominator) || denominator === 0 || !isFinite(numerator) || !isFinite(denominator)) {
return defaultVal;
}
return numerator / denominator;
}
function calculateLog10FeeComponent(balance) {
if (isNaN(balance) || !isFinite(balance)) return 1.0;
const absBalance = Math.abs(balance);
if (absBalance < 1.0) return 1.0;
return Math.max(1.0, Math.log10(absBalance));
}
function getAvgTransactionFeeRate(entity1, entity2) {
const balance1 = entity1.avgBalance !== undefined ? entity1.avgBalance : entity1.balance;
const balance2 = entity2.avgBalance !== undefined ? entity2.avgBalance : entity2.balance;
const feeComp1 = calculateLog10FeeComponent(balance1);
const feeComp2 = calculateLog10FeeComponent(balance2);
const avgFeeRate = (feeComp1 + feeComp2) / 2;
return Math.max(0.1, Math.min(isNaN(avgFeeRate) ? 1.0 : avgFeeRate, 10));
}
function getCohortEmoji(cohortName) {
switch (cohortName) {
case "Impoverished": return "🧑‍🦲";
case "Low Income": return "🧑";
case "Middle Income": return "🧑‍💼";
case "High Income": return "🧐";
default: return "";
}
}
function logEvent(message, type = "info") {
const eventLogStartTime = performance.now();
const p = document.createElement('p');
const displayDay = currentDay === 0 && currentYear === 0 ? 0 : currentDay;
p.textContent = `Y${currentYear}.D${displayDay}: ${message}`;
p.className = type;
if (eventLogEl.firstChild) eventLogEl.insertBefore(p, eventLogEl.firstChild);
else eventLogEl.appendChild(p);
while (eventLogEl.children.length > 100) eventLogEl.removeChild(eventLogEl.lastChild);
updateModuleSteptime('module-event-log', eventLogStartTime);
}
function recordTransaction(fromEntity, toEntity, amount, fee, type) {
const tx = new TransactionRecord(nextTransactionId++, currentYear, currentDay, fromEntity, toEntity, amount, fee, type);
transactionLog.push(tx);
// Optional: Limit the size of the full transactionLog to prevent memory issues in very long simulations
// if (transactionLog.length > 2000) { // Example cap
// transactionLog.shift();
// }
}
// --- Simulation Parameters & State ---
const paramInputs = {
initialPersonalCredit: document.getElementById('initial-personal-credit'),
initialBusinessCredit: document.getElementById('initial-business-credit'),
baseAdoptionRate: document.getElementById('base-adoption-rate'),
transactionFeeSplitUbi: document.getElementById('transaction-fee-split-ubi'),
grantFocus: document.getElementById('grant-focus'),
avgVelocityMultiplier: document.getElementById('avg-velocity-multiplier'),
redMoneyTolerance: document.getElementById('red-money-tolerance'),
innovationInvestmentPropensity: document.getElementById('innovation-investment-propensity'),
daysInYear: document.getElementById('days-in-year'),
yearlyTimeBudget: document.getElementById('yearly-time-budget'),
uiUpdateFrequency: document.getElementById('ui-update-frequency')
};
const paramValueDisplays = {
baseAdoptionRate: document.getElementById('base-adoption-rate-value'),
transactionFeeSplitUbi: document.getElementById('transaction-fee-split-ubi-value'),
avgVelocityMultiplier: document.getElementById('avg-velocity-multiplier-value'), // Added this line
redMoneyTolerance: document.getElementById('red-money-tolerance-value'),
innovationInvestmentPropensity: document.getElementById('innovation-investment-propensity-value'),
yearlyTimeBudget: document.getElementById('yearly-time-budget-value')
};
const metricEls = {
avgUbi: document.getElementById('avg-ubi'),
giniCoefficient: document.getElementById('gini-coefficient'),
povertyRate: document.getElementById('poverty-rate'),
techLevel: document.getElementById('tech-level'),
ioubiAdoption: document.getElementById('ioubi-adoption'),
totalRedMoney: document.getElementById('total-red-money'),
totalPositiveBalances: document.getElementById('total-positive-balances'),
netSystemDeltars: document.getElementById('net-system-deltars'),
annualPoolRevenue: document.getElementById('annual-pool-revenue'),
creditUtilization: document.getElementById('credit-utilization'),
socialCreditLent: document.getElementById('social-credit-lent'),
activeSocialProjects: document.getElementById('active-social-projects'),
};
let mainChart, lorenzChart, volumeChart;
const chartHistoryLimit = 50;
const dailyChartHistoryLimit = 365 * 2;
let individualStatsChartInstance = null; // For the new chart
// const INDIVIDUAL_CHART_HISTORY_LIMIT = 50; // Max points for individual chart - REMOVED
let individualChartDataStore = {}; // To store data for each cohort/timeframe
let cumulativeProcessingTimeMs = 0; // For total simulation time
const chartData = { labels: [], datasets: [ { label: 'Avg. Daily UBI (Δ)', data: [], borderColor: '#fca311', tension: 0.2, yAxisID: 'yUbi', pointRadius: 1, borderWidth: 2 }, { label: 'Gini Coefficient', data: [], borderColor: '#87CEEB', tension: 0.2, yAxisID: 'yGini', pointRadius: 1, borderWidth: 2 }, { label: 'IOUBI Adoption (%)', data: [], borderColor: '#90EE90', tension: 0.2, yAxisID: 'yGini', pointRadius: 1, borderWidth: 2 }, { label: 'Tech Level', data: [], borderColor: '#FFD700', tension: 0.2, yAxisID: 'yUbi', pointRadius: 1, borderWidth: 2, hidden: true } ] };
const lorenzChartData = { labels: Array.from({length: 101}, (_, i) => i), datasets: [ { label: 'Perfect Equality', data: Array.from({length: 101}, (_, i) => i), borderColor: '#777', borderDash: [5, 5], pointRadius: 0, borderWidth: 1, fill: false }, { label: 'Lorenz Curve', data: [], borderColor: '#FF6347', tension: 0.1, pointRadius: 0, borderWidth: 2, fill: 'origin', backgroundColor: 'rgba(255, 99, 71, 0.2)' } ] };
const volumeChartData = { labels: [], datasets: [ { label: 'Daily Transaction Volume (Δ)', data: [], borderColor: '#4BC0C0', tension: 0.2, yAxisID: 'yVolume', pointRadius: 0, borderWidth: 1.5 }, { label: 'Daily Pool Revenue (Δ)', data: [], borderColor: '#FFCE56', tension: 0.2, yAxisID: 'yRevenue', pointRadius: 0, borderWidth: 1.5, hidden: true } ] };
// Temporary stores for volume chart daily data
let tempVolumeChartLabels = [];
let tempVolumeChartVolumeData = [];
let tempVolumeChartRevenueData = [];
const MAX_YEARS = 100;
let currentYear = 0;
let currentDay = 0;
let DAYS_PER_YEAR = parseInt(paramInputs.daysInYear.value);
const GLOBAL_POPULATION = 7 * 10**9;
let ioubiUserCount = GLOBAL_POPULATION * 0.001;
class TransactionRecord { constructor(id, year, day, fromEntity, toEntity, amount, fee, type) { this.id = id; this.timestamp = `Y${year}.D${day}`; this.fromEntity = fromEntity; this.toEntity = toEntity; this.amount = amount; this.fee = fee; this.type = type; } }
let transactionLog = [];
let nextTransactionId = 1;
const MAX_TRANSACTION_LOG_DISPLAY = 10;
let groupAccounts = [];
let nextGroupId = 1;
let agentCohorts = [
{ name: "Impoverished", popShare: 0.60, balance: -100, creditLimit: 200, baseSpendingPerCapita: 1000, wellBeing: 0.1, redMoneyToleranceMod: 1.5, actualPop: 0, socialCreditAvailableToLend: 0, socialCreditBorrowed: 0, innovationScore: 0.05 },
{ name: "Low Income", popShare: 0.25, balance: 50, creditLimit: 500, baseSpendingPerCapita: 5000, wellBeing: 0.3, redMoneyToleranceMod: 1.2, actualPop: 0, socialCreditAvailableToLend: 50, socialCreditBorrowed: 0, innovationScore: 0.1 },
{ name: "Middle Income", popShare: 0.12, balance: 1000, creditLimit: 2000, baseSpendingPerCapita: 20000, wellBeing: 0.6, redMoneyToleranceMod: 1.0, actualPop: 0, socialCreditAvailableToLend: 200, socialCreditBorrowed: 0, innovationScore: 0.2 },
{ name: "High Income", popShare: 0.03, balance: 10000, creditLimit: 10000, baseSpendingPerCapita: 100000, wellBeing: 0.8, redMoneyToleranceMod: 0.5, actualPop: 0, socialCreditAvailableToLend: 1000, socialCreditBorrowed: 0, innovationScore: 0.4 }
];
let businessCohort = { name: "Businesses", count: 0, avgBalance: 5000, creditLimit: 10000, spendingToIndividuals: 0, spendingToBusinesses: 0, innovationScore: 0.3 };
let globalStats = { avgUbi: 0, giniCoefficient: 0.85, povertyRate: 80, techLevel: 1.0, totalRedMoney: 0, totalPositiveBalances: 0, netSystemDeltars: 0, annualPoolRevenue: 0, totalTransactionVolumeYear: 0, dailyTransactionVolume:0, dailyPoolRevenue: 0, totalCreditLimitSystem: 0, socialCreditLent: 0, activeSocialProjects: 0 };
let accumulatedGrantForSocialProjects = 0; // New global variable
// --- Chart Initialization ---
function initCharts() {
Chart.defaults.color = '#a7a9be';
const mainCtx = document.getElementById('main-chart').getContext('2d');
mainChart = new Chart(mainCtx, { type: 'line', data: chartData, options: { responsive: true, maintainAspectRatio: false, scales: { x: { title: { display: true, text: 'Year' }, grid: { color: '#243b55' } }, yUbi: { type: 'linear', display: true, position: 'left', title: { display: true, text: 'Avg. Daily UBI (Δ) / Tech Level' }, beginAtZero: true, grid: { color: '#2e5077' } }, yGini: { type: 'linear', display: true, position: 'right', title: { display: true, text: 'Gini / Adoption(%)' }, grid: { drawOnChartArea: false }, min: 0, max:1 } }, plugins: { legend: { labels: { color: '#c5c6d0' } } }, animation: { duration: 150 } } });
const lorenzCtx = document.getElementById('lorenz-chart').getContext('2d');
lorenzChart = new Chart(lorenzCtx, { type: 'line', data: lorenzChartData, options: { responsive: true, maintainAspectRatio: false, scales: { x: { title: { display: true, text: 'Cumulative % of Population' }, min:0, max:100, grid: { color: '#243b55' } }, y: { title: { display: true, text: 'Cumulative % of Wealth' }, min:0, max:100, grid: { color: '#2e5077' } } }, plugins: { legend: { display: true, labels: { color: '#c5c6d0'} } }, animation: { duration: 150 } } });
const volumeCtx = document.getElementById('volume-chart').getContext('2d');
volumeChart = new Chart(volumeCtx, { type: 'line', data: volumeChartData, options: { responsive: true, maintainAspectRatio: false, scales: { x: { title: { display: true, text: 'Time (Year.Day)' }, grid: { color: '#243b55' }, ticks: { autoSkip: true, maxTicksLimit: 20 } }, yVolume: { type: 'linear', display: true, position: 'left', title: { display: true, text: 'Transaction Volume (Δ)' }, beginAtZero: true, grid: { color: '#2e5077' } }, yRevenue: { type: 'linear', display: true, position: 'right', title: { display: true, text: 'Pool Revenue (Δ)' }, beginAtZero: true, grid: { drawOnChartArea: false } } }, plugins: { legend: { labels: { color: '#c5c6d0' } } }, animation: { duration: 0 } } });
}
// --- Individual/Cohort Stats Chart Logic ---
function populateIndividualStatsDropdowns() {
const cohortOptions = [
{ value: "random_sample", text: "Random Sampled Individual" },
{ value: "impoverished_rep", text: "Impoverished Cohort Rep." },
{ value: "low_income_rep", text: "Low Income Cohort Rep." },
{ value: "middle_income_rep", text: "Middle Income Cohort Rep." },
{ value: "high_income_rep", text: "High Income Cohort Rep." }
];
cohortOptions.forEach(opt => {
const optionEl = document.createElement('option');
optionEl.value = opt.value;
optionEl.textContent = opt.text;
individualCohortSelectEl.appendChild(optionEl);
});
if (individualCohortSelectEl.options.length > 0) {
individualCohortSelectEl.selectedIndex = 0; // Default to first option
}
const timeframeOptions = [
{ value: "daily", text: "Daily" },
{ value: "monthly", text: "Monthly" },
{ value: "yearly", text: "Yearly" }
];
timeframeOptions.forEach(opt => {
const optionEl = document.createElement('option');
optionEl.value = opt.value;
optionEl.textContent = opt.text;
individualTimeframeSelectEl.appendChild(optionEl);
});
if (individualTimeframeSelectEl.options.length > 0) {
individualTimeframeSelectEl.value = "monthly"; // Default to monthly
}
}
function getHistoryLimitInPoints(retentionValue, timeframe, daysInYearSim) {
if (retentionValue === "all_time") return Infinity;
const years = parseInt(retentionValue.split('_')[0]);
if (isNaN(years)) return 50; // Default fallback
switch (timeframe) {
case 'daily': return years * daysInYearSim;
case 'monthly': return years * 12;
case 'yearly': return years;
default: return 50; // Default fallback
}
}
function initializeIndividualCohortStoreEntry(cohortKey) {
if (!individualChartDataStore[cohortKey]) {
individualChartDataStore[cohortKey] = {
labels: [],
// Structure matches datasets returned by fetchDataForIndividualStatsChart
datasets: [
{ label: 'Income (UBI + Other)', data: [], borderColor: 'rgba(75, 192, 192, 1)', tension: 0.1, fill: false, yAxisID: 'yMonetary' },
{ label: 'Spent (Outflows)', data: [], borderColor: 'rgba(255, 99, 132, 1)', tension: 0.1, fill: false, yAxisID: 'yMonetary' },
{ label: 'Balance', data: [], borderColor: 'rgba(54, 162, 235, 1)', tension: 0.1, fill: false, yAxisID: 'yMonetary' },
{ label: 'Dividend (UBI)', data: [], borderColor: 'rgba(255, 206, 86, 1)', tension: 0.1, fill: false, yAxisID: 'yMonetary' },
{ label: 'Transaction Count', data: [], borderColor: 'rgba(153, 102, 255, 1)', type: 'bar', yAxisID: 'yTxCount' }
],
lastSampledCohortName: null // ADDED for random sample tracking
};
if (cohortKey === "random_sample") {
individualChartDataStore[cohortKey].pointSampledFromCohortNames = [];
}
}
}
function updateAllIndividualCohortStores() {
const currentTimeframe = individualTimeframeSelectEl.value;
const currentRetention = individualLogRetentionSelectEl.value;
const limit = getHistoryLimitInPoints(currentRetention, currentTimeframe, DAYS_PER_YEAR);
for (let i = 0; i < individualCohortSelectEl.options.length; i++) {
const cohortKey = individualCohortSelectEl.options[i].value;
initializeIndividualCohortStoreEntry(cohortKey); // Ensure it exists
const storeEntry = individualChartDataStore[cohortKey];
const currentSnapshot = fetchDataForIndividualStatsChart(cohortKey, currentTimeframe);
// Store the last sampled cohort name if this is the random_sample entry
if (cohortKey === "random_sample" && currentSnapshot.sampledCohortName) {
storeEntry.lastSampledCohortName = currentSnapshot.sampledCohortName;
}
const lastLabelInStore = storeEntry.labels.length > 0 ? storeEntry.labels[storeEntry.labels.length - 1] : null;
let pointShouldBeAdded = false;
if (currentSnapshot.labels[0] === "Initial State") {
if (storeEntry.labels.length === 0) { // Only add "Initial State" if store is empty for this cohort
pointShouldBeAdded = true;
}
} else { // Regular data point
if (lastLabelInStore !== currentSnapshot.labels[0]) {
pointShouldBeAdded = true;
} else if (storeEntry.labels.length === 0) {
pointShouldBeAdded = true;
}
}
if (pointShouldBeAdded) {
storeEntry.labels.push(currentSnapshot.labels[0]);
currentSnapshot.datasets.forEach((newDs, index) => {
if (storeEntry.datasets[index]) { // Should always exist due to initialization
storeEntry.datasets[index].data.push(newDs.data[0]);
}
});
if (cohortKey === "random_sample" && storeEntry.pointSampledFromCohortNames) {
storeEntry.pointSampledFromCohortNames.push(currentSnapshot.sampledCohortName || "N/A");
}
}
// Enforce history limit for this specific cohort's store
while (storeEntry.labels.length > limit && limit !== Infinity) {
storeEntry.labels.shift();
storeEntry.datasets.forEach(ds => ds.data.shift());
if (cohortKey === "random_sample" && storeEntry.pointSampledFromCohortNames && storeEntry.pointSampledFromCohortNames.length > 0) {
storeEntry.pointSampledFromCohortNames.shift();
}
}
}
}
function fetchDataForIndividualStatsChart(cohortSelection, timeframe) {
let sCohort = null;
let labels = [];
let incomeData = [], spentData = [], balanceData = [], dividendData = [], txCountData = [];
let sampledCohortName = null; // ADDED
const cohortMap = {
"impoverished_rep": "Impoverished",
"low_income_rep": "Low Income",
"middle_income_rep": "Middle Income",
"high_income_rep": "High Income"
};
// Select the target cohort
if (cohortSelection === "random_sample") {
const activeCohorts = agentCohorts.filter(c => c.actualPop > 0);
sCohort = activeCohorts.length > 0 ? activeCohorts[Math.floor(Math.random() * activeCohorts.length)]
: agentCohorts.find(c => c.name === "Impoverished") || agentCohorts[0];
if (sCohort) sampledCohortName = sCohort.name; // CAPTURE sampled cohort name
} else {
sCohort = agentCohorts.find(c => c.name === cohortMap[cohortSelection]);
}
if (!sCohort) {
sCohort = agentCohorts[0] || { name: "Fallback", balance: 0, baseSpendingPerCapita: 1000, wellBeing: 0 };
}
// Check if simulation has started
const simulationStarted = (currentYear > 0 || currentDay > 0);
if (!simulationStarted) {
labels = ["Initial State"];
// Use the cohort's initial balance (from definition)
let initialBalance = 0;
if (sCohort.name === "Impoverished") initialBalance = -100;
else if (sCohort.name === "Low Income") initialBalance = 50;
else if (sCohort.name === "Middle Income") initialBalance = 1000;
else if (sCohort.name === "High Income") initialBalance = 10000;
else initialBalance = sCohort.balance || 0;
incomeData = [0];
spentData = [0];
balanceData = [initialBalance];
dividendData = [0];
txCountData = [0];
} else {
const dailyUbi = globalStats.avgUbi || 0;
const dailyOtherIncome = Math.max(0, sCohort.balance > 0 ? sCohort.balance * 0.00005 : 0);
const totalDailyIncome = dailyUbi + dailyOtherIncome;
const baseDailySpending = (sCohort.baseSpendingPerCapita / DAYS_PER_YEAR);
const wellBeingFactor = (1 + sCohort.wellBeing * 0.5);
let velocity = 0.2;
try {
velocity = parseFloat(paramInputs.avgVelocityMultiplier.value);
if (isNaN(velocity) || !isFinite(velocity)) velocity = 0.2;
} catch(e){ velocity = 0.2; }
const estimatedDailySpending = baseDailySpending * wellBeingFactor * velocity;
const currentBalance = sCohort.balance;
// Estimate transaction count
let estimatedDailyTxCount = 1;
try {
let baseTx = parseFloat(paramInputs.avgVelocityMultiplier.value);
if (isNaN(baseTx) || !isFinite(baseTx)) baseTx = 0.2;
estimatedDailyTxCount = Math.max(1, Math.floor(baseTx * 5));
} catch(e){ /* use default */ }
let currentLabel = "";
if (timeframe === 'daily') {
currentLabel = `Y${currentYear}.D${currentDay}`;
incomeData = [totalDailyIncome];
spentData = [estimatedDailySpending];
balanceData = [currentBalance];
dividendData = [dailyUbi];
txCountData = [estimatedDailyTxCount];
} else if (timeframe === 'monthly') {
const monthNum = currentDay > 0 ? Math.max(1, Math.ceil(currentDay / (DAYS_PER_YEAR / 12))) : (currentYear > 0 ? 1 : 0);
if (monthNum === 0) { // Should only be Y0 D0, handled by !simulationStarted
currentLabel = "Initial State"; // Fallback
} else {
currentLabel = `Y${currentYear}.M${monthNum}`;
}
const factor = DAYS_PER_YEAR / 12;
incomeData = [totalDailyIncome * factor];
spentData = [estimatedDailySpending * factor];
balanceData = [currentBalance]; // Balance is a snapshot, not cumulative for the period
dividendData = [dailyUbi * factor];
txCountData = [Math.round(estimatedDailyTxCount * factor)];
} else { // yearly
currentLabel = `Y${currentYear}`;
incomeData = [totalDailyIncome * DAYS_PER_YEAR];
spentData = [estimatedDailySpending * DAYS_PER_YEAR];
balanceData = [currentBalance]; // Balance is a snapshot
dividendData = [dailyUbi * DAYS_PER_YEAR];
txCountData = [Math.round(estimatedDailyTxCount * DAYS_PER_YEAR)];
}
labels = [currentLabel];
}
return {
labels: labels,
datasets: [
{ label: 'Income (UBI + Other)', data: incomeData, borderColor: 'rgba(75, 192, 192, 1)', tension: 0.1, fill: false, yAxisID: 'yMonetary' },
{ label: 'Spent (Outflows)', data: spentData, borderColor: 'rgba(255, 99, 132, 1)', tension: 0.1, fill: false, yAxisID: 'yMonetary' },
{ label: 'Balance', data: balanceData, borderColor: 'rgba(54, 162, 235, 1)', tension: 0.1, fill: false, yAxisID: 'yMonetary' },
{ label: 'Dividend (UBI)', data: [], borderColor: 'rgba(255, 206, 86, 1)', tension: 0.1, fill: false, yAxisID: 'yMonetary' },
{ label: 'Transaction Count', data: txCountData, borderColor: 'rgba(153, 102, 255, 1)', type: 'bar', yAxisID: 'yTxCount' }
],
sampledCohortName: sampledCohortName
};
}
function renderOrUpdateIndividualStatsChart() {
if (!individualStatsChartCanvasEl) return;
const individualStatsStartTime = performance.now(); // Measure start time
const selectedCohortKey = individualCohortSelectEl.value;
const selectedTimeframe = individualTimeframeSelectEl.value;
const selectedRetention = individualLogRetentionSelectEl.value;
const ctx = individualStatsChartCanvasEl.getContext('2d');
let cohortText = "Selected Cohort/Individual"; // Default text
if (individualCohortSelectEl.selectedIndex !== -1 && individualCohortSelectEl.options[individualCohortSelectEl.selectedIndex]) {
cohortText = individualCohortSelectEl.options[individualCohortSelectEl.selectedIndex].text;
}
let timeframeText = "Selected Timeframe"; // Default text
if (individualTimeframeSelectEl.selectedIndex !== -1 && individualTimeframeSelectEl.options[individualTimeframeSelectEl.selectedIndex]) {
timeframeText = individualTimeframeSelectEl.options[individualTimeframeSelectEl.selectedIndex].text;
}
// --- Construct dynamic title ---
let chartTitleText = `Stats for ${cohortText} (${timeframeText})`;
if (selectedCohortKey === "random_sample") {
const storeEntry = individualChartDataStore[selectedCohortKey];
const lastSampledName = storeEntry?.lastSampledCohortName;
if (lastSampledName) {
chartTitleText = `Stats for Random Sampled Individual (from ${lastSampledName}) (${timeframeText})`;
} else {
chartTitleText = `Stats for Random Sampled Individual (${timeframeText})`;
}
}
// --- End dynamic title construction ---
initializeIndividualCohortStoreEntry(selectedCohortKey); // Ensure store entry exists
let chartDataSource = individualChartDataStore[selectedCohortKey];
// If the store for this cohort is empty (e.g. first time viewing, pre-simulation), populate with an initial point.
if (chartDataSource.labels.length === 0) {
const initialSnapshot = fetchDataForIndividualStatsChart(selectedCohortKey, selectedTimeframe);
chartDataSource.labels.push(initialSnapshot.labels[0]);
initialSnapshot.datasets.forEach((newDs, index) => {
if (chartDataSource.datasets[index]) {
chartDataSource.datasets[index].data.push(newDs.data[0]);
}
});
if (selectedCohortKey === "random_sample" && chartDataSource.pointSampledFromCohortNames) {
chartDataSource.pointSampledFromCohortNames.push(initialSnapshot.sampledCohortName || "N/A");
}
}
// Apply retention limit if retention setting changed for the current view
// This is mostly for when user changes retention, data is trimmed from store.
const currentLimit = getHistoryLimitInPoints(selectedRetention, selectedTimeframe, DAYS_PER_YEAR);
while (chartDataSource.labels.length > currentLimit && currentLimit !== Infinity) {
chartDataSource.labels.shift();
chartDataSource.datasets.forEach(ds => ds.data.shift());
if (selectedCohortKey === "random_sample" && chartDataSource.pointSampledFromCohortNames && chartDataSource.pointSampledFromCohortNames.length > 0) {
chartDataSource.pointSampledFromCohortNames.shift();
}
}
if (!individualStatsChartInstance) {
individualStatsChartInstance = new Chart(ctx, {
type: 'line',
data: { // Use a deep copy for the chart instance to avoid direct mutation issues with Chart.js
labels: [...chartDataSource.labels],
datasets: chartDataSource.datasets.map(ds => ({ ...ds, data: [...ds.data] }))
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
title: { display: true, text: chartTitleText, color: '#e4bb97' }, // USE DYNAMIC TITLE
legend: { labels: { color: '#c5c6d0' } },
tooltip: {
callbacks: {
afterBody: function(tooltipItems) {
if (selectedCohortKey === "random_sample" && tooltipItems.length > 0) {
const dataIndex = tooltipItems[0].dataIndex;
const storeEntry = individualChartDataStore[selectedCohortKey];
if (storeEntry && storeEntry.pointSampledFromCohortNames && storeEntry.pointSampledFromCohortNames[dataIndex]) {
const sampledCohortNameForPoint = storeEntry.pointSampledFromCohortNames[dataIndex];
const emoji = getCohortEmoji(sampledCohortNameForPoint);
return `\nSampled from: ${emoji} ${sampledCohortNameForPoint}`;
}
}
return '';
}
}
}
},
scales: {
x: { title: { display: true, text: 'Time Period' }, grid: { color: '#243b55' }, ticks: { color: '#a7a9be'} },
yMonetary: {
type: 'linear', display: true, position: 'left',
title: { display: true, text: 'Amount (Δ)' },
grid: { color: '#2e5077' }, ticks: { color: '#a7a9be'}
},
yTxCount: {
type: 'linear',
display: chartDataSource.datasets.some(ds => ds.yAxisID === 'yTxCount' && ds.data && ds.data.length > 0), // Use chartDataSource here
position: 'right',
title: { display: true, text: 'Transaction Count' },
grid: { drawOnChartArea: false }, beginAtZero: true, ticks: { color: '#a7a9be'}
}
},
animation: { duration: 0 } // No animation for initial render either
}
});
} else {
// If selection changed (cohort, timeframe, or retention), reload data from store
if (individualStatsChartInstance.currentCohortSelection !== selectedCohortKey ||
individualStatsChartInstance.currentTimeframe !== selectedTimeframe ||
individualStatsChartInstance.currentLogRetention !== selectedRetention) {
// Update chart instance data directly from the (potentially trimmed) store
individualStatsChartInstance.data.labels = [...chartDataSource.labels];
individualStatsChartInstance.data.datasets.forEach((ds, i) => {
// Ensure dataset structure matches, then update data
if (chartDataSource.datasets[i]) {
ds.label = chartDataSource.datasets[i].label;
ds.borderColor = chartDataSource.datasets[i].borderColor;
ds.tension = chartDataSource.datasets[i].tension;
ds.fill = chartDataSource.datasets[i].fill;
ds.yAxisID = chartDataSource.datasets[i].yAxisID;
ds.type = chartDataSource.datasets[i].type; // For bar chart part
ds.data = [...chartDataSource.datasets[i].data];
}
});
}
// Data is already up-to-date in chartDataSource due to updateAllIndividualCohortStores or initial load/trim.
// So, we just ensure the chart instance reflects the current state of chartDataSource.
individualStatsChartInstance.data.labels = [...chartDataSource.labels];
individualStatsChartInstance.data.datasets.forEach((ds, i) => {
if (chartDataSource.datasets[i]) { // defensive
ds.data = [...chartDataSource.datasets[i].data];
}
});
individualStatsChartInstance.options.plugins.title.text = chartTitleText; // USE DYNAMIC TITLE
// Update tooltip callback if it wasn't set initially or needs to refresh context (though selectedCohortKey is from outer scope)
individualStatsChartInstance.options.plugins.tooltip.callbacks.afterBody = function(tooltipItems) {
if (selectedCohortKey === "random_sample" && tooltipItems.length > 0) {
const dataIndex = tooltipItems[0].dataIndex;
const storeEntry = individualChartDataStore[selectedCohortKey];
if (storeEntry && storeEntry.pointSampledFromCohortNames && storeEntry.pointSampledFromCohortNames[dataIndex]) {
const sampledCohortNameForPoint = storeEntry.pointSampledFromCohortNames[dataIndex];
const emoji = getCohortEmoji(sampledCohortNameForPoint);
return `\nSampled from: ${emoji} ${sampledCohortNameForPoint}`;
}
}
return '';
};
const hasTxCount = chartDataSource.datasets.some(ds => ds.yAxisID === 'yTxCount' && ds.data.length > 0 && ds.data.some(d => d !== undefined && d !== null));
if (individualStatsChartInstance.options.scales.yTxCount) {
individualStatsChartInstance.options.scales.yTxCount.display = hasTxCount;
}
individualStatsChartInstance.update('none');
}
// Store current selection to detect changes next time
if (individualStatsChartInstance) {
individualStatsChartInstance.currentCohortSelection = selectedCohortKey;
individualStatsChartInstance.currentTimeframe = selectedTimeframe;
individualStatsChartInstance.currentLogRetention = selectedRetention;
}
updateModuleSteptime('module-individual-stats', individualStatsStartTime); // Update steptime
}
// --- Simulation Logic ---
function simulateDayStep(params, dayOfYear) {
globalStats.dailyTransactionVolume = 0;
let dailyFeesCollected = 0;
const dynamicVelocity = params.avgVelocity * (0.8 + Math.random() * 0.4 + (1-globalStats.giniCoefficient) * 0.1);
// --- Daily New User Adoption ---
if (params.newUsersPerDay > 0) {
ioubiUserCount += params.newUsersPerDay;
agentCohorts.forEach(cohort => {
const newToCohortDaily = params.newUsersPerDay * cohort.popShare;
if (newToCohortDaily === 0 && cohort.actualPop === 0) return;
const oldPop = cohort.actualPop;
cohort.actualPop += newToCohortDaily;
if (oldPop > 0) {
const weightedOldBalance = cohort.balance * oldPop;
const newUsersInitialDebt = params.initialPersonalCredit * 0.5 * newToCohortDaily;
cohort.balance = safeDiv(weightedOldBalance - newUsersInitialDebt, cohort.actualPop, cohort.balance);
const weightedOldCreditLimit = cohort.creditLimit * oldPop;
const newUsersCreditLimit = params.initialPersonalCredit * newToCohortDaily;
cohort.creditLimit = safeDiv(weightedOldCreditLimit + newUsersCreditLimit, cohort.actualPop, cohort.creditLimit);
} else if (newToCohortDaily > 0) {
cohort.balance = -params.initialPersonalCredit * 0.5;
cohort.creditLimit = params.initialPersonalCredit;
}
});
businessCohort.count = Math.max(10, Math.floor(ioubiUserCount * 0.05));
if (currentYear === 1 && dayOfYear === 1 && businessCohort.count > 0) {
businessCohort.avgBalance = params.initialBusinessCredit * 0.2;
businessCohort.creditLimit = params.initialBusinessCredit;
}
}
agentCohorts.forEach(actingCohort => {
if (actingCohort.actualPop <= 0) return;
let dailySpendingTotal = (actingCohort.baseSpendingPerCapita / DAYS_PER_YEAR) * actingCohort.actualPop * (1 + actingCohort.wellBeing * 0.5);
let desiredBalance = -actingCohort.creditLimit * params.redMoneyToleranceFactor * actingCohort.redMoneyToleranceMod;
if (actingCohort.balance < desiredBalance) dailySpendingTotal *= 0.8;
else if (actingCohort.balance > actingCohort.creditLimit * 0.5) dailySpendingTotal *= 1.2;
agentCohorts.forEach(receivingCohort => {
if (actingCohort === receivingCohort || receivingCohort.actualPop <= 0) return;
let transactionAmountForPair = (dailySpendingTotal * receivingCohort.popShare) * dynamicVelocity;
if (isNaN(transactionAmountForPair) || transactionAmountForPair <= 0.01) return;
globalStats.dailyTransactionVolume += transactionAmountForPair;
const feeRate = getAvgTransactionFeeRate(actingCohort, receivingCohort);
const fees = transactionAmountForPair * (feeRate / 100);
dailyFeesCollected += fees;
actingCohort.balance -= safeDiv(transactionAmountForPair, actingCohort.actualPop, 0);
receivingCohort.balance += safeDiv(transactionAmountForPair - fees, receivingCohort.actualPop, 0);
if (Math.random() < 0.001) {
recordTransaction(actingCohort.name, receivingCohort.name, transactionAmountForPair, fees, 'C2C');
}
});
});
if (businessCohort.count > 0) {
let businessDailyTotalSpending = (businessCohort.count * 50000 / DAYS_PER_YEAR) * (1 + globalStats.techLevel * 0.1);
let dailySpendingToIndividuals = businessDailyTotalSpending * 0.6;
let dailySpendingToBusinesses = businessDailyTotalSpending * 0.4;
agentCohorts.forEach(cohort => {
if (cohort.actualPop <= 0) return;
let amountToCohort = (dailySpendingToIndividuals * cohort.popShare) * dynamicVelocity;
if (isNaN(amountToCohort) || amountToCohort <= 0.01) return;
globalStats.dailyTransactionVolume += amountToCohort;
const feeRate = getAvgTransactionFeeRate(businessCohort, cohort);
const fees = amountToCohort * (feeRate / 100);
dailyFeesCollected += fees;
cohort.balance += safeDiv(amountToCohort - fees, cohort.actualPop, 0);
businessCohort.avgBalance -= safeDiv(amountToCohort, businessCohort.count, 0);
if (Math.random() < 0.005) {
recordTransaction(businessCohort.name, cohort.name, amountToCohort, fees, 'B2C');
}
});
let b2bTransactions = dailySpendingToBusinesses * dynamicVelocity;
if (!isNaN(b2bTransactions) && b2bTransactions > 0.01) {
globalStats.dailyTransactionVolume += b2bTransactions;
const b2bFeeRate = getAvgTransactionFeeRate(businessCohort, businessCohort);
const b2bFees = b2bTransactions * (b2bFeeRate / 100);
dailyFeesCollected += b2bFees;
if (Math.random() < 0.005) {
recordTransaction(businessCohort.name, businessCohort.name, b2bTransactions, b2bFees, 'B2B');
}
}
}
groupAccounts.forEach(ga => {
if (ga.balance > 0) {
let dailyProjectSpending = Math.min(ga.balance, (ga.initialFunding || 1000) / (DAYS_PER_YEAR * 2));
if (dailyProjectSpending > 0.01) {
ga.balance -= dailyProjectSpending;
if (ga.purpose === 'technology') globalStats.techLevel += dailyProjectSpending * 0.0001 * ga.impactFactor;
else if (ga.purpose === 'social_welfare') agentCohorts.forEach(c => { if(c.actualPop > 0) c.wellBeing = Math.min(1, c.wellBeing + dailyProjectSpending * 0.00001 * ga.impactFactor / c.actualPop)});
}
}
});
globalStats.annualPoolRevenue += dailyFeesCollected;
globalStats.totalTransactionVolumeYear += globalStats.dailyTransactionVolume;
// --- Daily Grant Distribution Effects ---
const dailyGrantAllocation = dailyFeesCollected * params.grantPoolSplit;
if (dailyGrantAllocation > 0 && ioubiUserCount > 0) {
const categories = params.grantFocus === "balanced" ? ["technology", "social_welfare", "environment", "infrastructure", "social_projects"] : [params.grantFocus];
const grantPerCategoryDaily = dailyGrantAllocation / categories.length;
categories.forEach(categoryFocus => {
const dailyCatGrantImpactFactor = safeDiv(grantPerCategoryDaily, ioubiUserCount, 0);
if (categoryFocus === "technology") {
globalStats.techLevel += safeDiv(dailyCatGrantImpactFactor, 100, 0) * (1 + params.innovationPropensity);
} else if (categoryFocus === "social_welfare") {
agentCohorts.forEach(c => c.wellBeing = Math.min(1, c.wellBeing + safeDiv(dailyCatGrantImpactFactor, 1500, 0)));
} else if (categoryFocus === "environment" || categoryFocus === "infrastructure") {
globalStats.techLevel += safeDiv(dailyCatGrantImpactFactor, 800, 0) * 0.5;
agentCohorts.forEach(c => c.wellBeing = Math.min(1, c.wellBeing + safeDiv(dailyCatGrantImpactFactor, 3000, 0)));
} else if (categoryFocus === "social_projects") {
accumulatedGrantForSocialProjects += grantPerCategoryDaily;
if (accumulatedGrantForSocialProjects >= 40000) {
const numNewProjects = Math.floor(accumulatedGrantForSocialProjects / 40000);
const projectFundingPerEach = 40000;
accumulatedGrantForSocialProjects -= numNewProjects * projectFundingPerEach;
for (let i = 0; i < numNewProjects; i++) {
const newProject = { id: nextGroupId++, name: `Social Project ${nextGroupId - 1}`, balance: projectFundingPerEach, initialFunding: projectFundingPerEach, contributors: [], purpose: 'social_welfare', impactFactor: 1.0 + Math.random() * 0.5, startYear: currentYear };
groupAccounts.push(newProject);
logEvent(`New Social Project '${newProject.name}' funded with ${formatNumber(projectFundingPerEach, 0)}Δ`, "positive");
recordTransaction('GrantSystem', 'SocialProject:' + newProject.name, projectFundingPerEach, 0, 'Grant');
}
}
}
});
}
groupAccounts = groupAccounts.filter(ga => ga.balance > 1 || (currentYear - (ga.startYear || 0)) < 5);
// --- Daily R&D Investment ---
let dailyTotalInnovationFunding = 0;
agentCohorts.forEach(cohort => {
if (cohort.actualPop > 0 && cohort.balance > 0) {
let cohortDailyInnovationInvestment = cohort.balance * cohort.actualPop * params.innovationPropensity * cohort.innovationScore * 0.1 / DAYS_PER_YEAR;
dailyTotalInnovationFunding += cohortDailyInnovationInvestment;
cohort.balance -= safeDiv(cohortDailyInnovationInvestment, cohort.actualPop, 0);
}
});
if (businessCohort.count > 0 && businessCohort.avgBalance > 0) {
let businessDailyInnovationInvestment = businessCohort.avgBalance * businessCohort.count * params.innovationPropensity * businessCohort.innovationScore * 0.1 / DAYS_PER_YEAR;
dailyTotalInnovationFunding += businessDailyInnovationInvestment;
businessCohort.avgBalance -= safeDiv(businessDailyInnovationInvestment, businessCohort.count);
}
if (dailyTotalInnovationFunding > 0) {
let currentTotalPositiveBalancesForRD = 0;
agentCohorts.forEach(c => { if(c.balance > 0 && c.actualPop > 0) currentTotalPositiveBalancesForRD += c.balance * c.actualPop });
if(businessCohort.avgBalance > 0 && businessCohort.count > 0) currentTotalPositiveBalancesForRD += businessCohort.avgBalance * businessCohort.count;
const techIncreaseDenominator = (currentTotalPositiveBalancesForRD + (businessCohort.count > 0 ? businessCohort.count * 1000 : 0) + 1) * 5;
const dailyTechIncreaseFromRD = safeDiv(dailyTotalInnovationFunding, techIncreaseDenominator, 0);
globalStats.techLevel += dailyTechIncreaseFromRD;
}
globalStats.techLevel += params.dailyBaseTechIncrease;
globalStats.techLevel = Math.max(1, isNaN(globalStats.techLevel) ? 1 : globalStats.techLevel);
// IOUBI model: fees vanish, UBI is distributed later based on tracked vanished fees.
const ubiPoolDaily = dailyFeesCollected * params.ubiPoolSplit;
const dailyUbiPerUser = safeDiv(ubiPoolDaily, ioubiUserCount, 0);
if (dailyUbiPerUser > 0) {
agentCohorts.forEach(cohort => {
if (cohort.actualPop > 0) {
cohort.balance += dailyUbiPerUser; // No tx, just a balance update
}
});
}
globalStats.avgUbi = dailyUbiPerUser;
globalStats.dailyPoolRevenue = dailyFeesCollected;
// --- Debugging output for UBI distribution logic ---
if (dailyUbiPerUser > 0) {
logEvent(`Daily UBI Distribution: ${formatNumber(dailyUbiPerUser, 2)}Δ per user`, "info");
}
}
async function simulateYear() {
if (currentYear >= MAX_YEARS) {
logEvent("Max simulation years reached.", "critical");
nextYearBtn.disabled = true; return;
}
const yearProcessingStartTime = performance.now(); // Start total time measurement for the year
document.getElementById('total-year-time-display').textContent = `Total Year Time: 0.0s`; // Reset for new year
document.getElementById('step-time-display').textContent = `Step Time: 0ms`; // Reset for new year
currentYear++;
currentYearEl.textContent = currentYear;
logEvent(`Year ${currentYear} simulation processing...`, "info");
nextYearBtn.disabled = true;
// Reset temporary volume chart data stores at the beginning of each year
tempVolumeChartLabels = [];
tempVolumeChartVolumeData = [];
tempVolumeChartRevenueData = [];
accumulatedGrantForSocialProjects = 0; // Reset accumulator for new year
DAYS_PER_YEAR = parseInt(paramInputs.daysInYear.value);
const yearlyTimeBudgetS = parseFloat(paramInputs.yearlyTimeBudget.value);
const uiUpdateFrequency = paramInputs.uiUpdateFrequency.value;
const timePerDayMs = Math.max(1, (yearlyTimeBudgetS * 1000) / DAYS_PER_YEAR);
globalStats.totalTransactionVolumeYear = 0;
globalStats.annualPoolRevenue = 0;
let accumulatedUbiForAnnualAvg = 0;
let daysSimulatedForUbiAvg = 0;
const govPoolSplit = 0.05; // <-- Explicitly define govPoolSplit here
const grantPoolSplit = Math.max(0, 1.0 - paramInputs.transactionFeeSplitUbi.value / 100 - govPoolSplit); // <-- Explicitly define grantPoolSplit here
const annualizedAvgUbi = globalStats.avgUbi * DAYS_PER_YEAR;
let potentialNewUsers = GLOBAL_POPULATION * paramInputs.baseAdoptionRate.value / 100 * (1 + annualizedAvgUbi / (paramInputs.initialPersonalCredit.value * 2));
potentialNewUsers *= Math.max(0.01, (1 - (ioubiUserCount / GLOBAL_POPULATION)));
const annualActualNewUsers = Math.max(0, Math.min(potentialNewUsers, GLOBAL_POPULATION - ioubiUserCount));
const newUsersPerDay = annualActualNewUsers / DAYS_PER_YEAR;
const params = {
...paramInputs,
newUsersPerDay,
dailyBaseTechIncrease: 0.005 / DAYS_PER_YEAR,
ubiPoolSplit: parseFloat(paramInputs.transactionFeeSplitUbi.value) / 100,
grantPoolSplit: grantPoolSplit,
avgVelocity: parseFloat(paramInputs.avgVelocityMultiplier.value),
redMoneyToleranceFactor: parseFloat(paramInputs.redMoneyTolerance.value),
innovationPropensity: parseFloat(paramInputs.innovationInvestmentPropensity.value),
initialPersonalCredit: parseFloat(paramInputs.initialPersonalCredit.value),
initialBusinessCredit: parseFloat(paramInputs.initialBusinessCredit.value),
grantFocus: paramInputs.grantFocus.value
};
for (let day = 1; day <= DAYS_PER_YEAR; day++) {
currentDay = day;
currentDayEl.textContent = day;
daysPerYearDisplayEl.textContent = DAYS_PER_YEAR;
const stepStartTime = performance.now();
simulateDayStep(params, day);
accumulatedUbiForAnnualAvg += globalStats.avgUbi;
daysSimulatedForUbiAvg++;
// Accumulate daily data for volume chart
if (typeof globalStats.dailyTransactionVolume === "number" && typeof globalStats.dailyPoolRevenue === "number") {
tempVolumeChartLabels.push(`Y${currentYear}.D${currentDay}`);
tempVolumeChartVolumeData.push(globalStats.dailyTransactionVolume);
tempVolumeChartRevenueData.push(globalStats.dailyPoolRevenue);
}
if (uiUpdateFrequency === 'daily' || (uiUpdateFrequency === 'monthly' && day % Math.max(1, Math.floor(DAYS_PER_YEAR / 12)) === 0) || (uiUpdateFrequency === 'yearly' && day === DAYS_PER_YEAR)) {
updateUIDisplay(false);
updateAllIndividualCohortStores(); // Update data for ALL cohorts in the store
renderOrUpdateIndividualStatsChart(); // Then render the currently selected one
// Update Volume Chart based on UI frequency
if (volumeChart && tempVolumeChartLabels.length > 0) {
// Add accumulated data to the main chart data arrays
volumeChartData.labels.push(...tempVolumeChartLabels);
volumeChartData.datasets[0].data.push(...tempVolumeChartVolumeData);
volumeChartData.datasets[1].data.push(...tempVolumeChartRevenueData);
// Enforce history limit
while (volumeChartData.labels.length > dailyChartHistoryLimit) {
volumeChartData.labels.shift();
volumeChartData.datasets[0].data.shift();
volumeChartData.datasets[1].data.shift();
}
volumeChart.update();
// Clear temporary arrays for the next accumulation period
tempVolumeChartLabels = [];
tempVolumeChartVolumeData = [];
tempVolumeChartRevenueData = [];
}
// Update total simulation time display more frequently
const currentTimeInYearForSimDisplay = performance.now() - yearProcessingStartTime; // Use current time for this specific display
const currentTotalSimTimeMs = cumulativeProcessingTimeMs + currentTimeInYearForSimDisplay;
const totalSecondsDisplay = (currentTotalSimTimeMs / 1000).toFixed(1);
document.getElementById('total-sim-time-display').textContent = `Total Sim Time: ${totalSecondsDisplay}s`;
}
const stepEndTime = performance.now();
const stepDurationMs = Math.round(stepEndTime - stepStartTime);
document.getElementById('step-time-display').textContent = `Step Time: ${stepDurationMs}ms`;
const currentTimeInYearMs = stepEndTime - yearProcessingStartTime;
document.getElementById('total-year-time-display').textContent = `Total Year Time: ${(currentTimeInYearMs / 1000).toFixed(1)}s`;
await new Promise(resolve => setTimeout(resolve, timePerDayMs));
}
currentDay = 0;
globalStats.avgUbi = safeDiv(accumulatedUbiForAnnualAvg, daysSimulatedForUbiAvg, 0);
const totalFeesCollectedThisYear = globalStats.annualPoolRevenue;
const govPoolTotal = totalFeesCollectedThisYear * govPoolSplit;
const grantPoolTotal = totalFeesCollectedThisYear * grantPoolSplit;
if (grantPoolTotal > 0 && !isNaN(grantPoolTotal)) {
logEvent(`Grant Pool: ${formatNumber(grantPoolTotal,1)}Δ for ${params.grantFocus}.`, "info");
if (params.grantFocus === "balanced") {
const categories = ["technology", "social_welfare", "environment", "infrastructure", "social_projects"];
const numCategories = categories.length;
const grantPerCategory = grantPoolTotal / numCategories;
categories.forEach(categoryFocus => {
const categoryGrantImpactFactor = safeDiv(grantPerCategory, ioubiUserCount, 0);
logEvent(`Balanced Distribution: ${formatNumber(grantPerCategory,1)}Δ to ${categoryFocus}.`, "info");
if (categoryFocus === "technology") {
globalStats.techLevel += safeDiv(categoryGrantImpactFactor, 100, 0) * (1 + params.innovationPropensity);
} else if (categoryFocus === "social_welfare") {
agentCohorts.forEach(c => c.wellBeing = Math.min(1, c.wellBeing + safeDiv(categoryGrantImpactFactor, 1500, 0)));
} else if (categoryFocus === "environment" || categoryFocus === "infrastructure") { // Combined logic for these two
globalStats.techLevel += safeDiv(categoryGrantImpactFactor, 800, 0) * 0.5;
agentCohorts.forEach(c => c.wellBeing = Math.min(1, c.wellBeing + safeDiv(categoryGrantImpactFactor, 3000, 0)));
} else if (categoryFocus === "social_projects" && ioubiUserCount > 0) {
if (grantPerCategory > 0) { // Ensure there's funding for this category
const numNewProjects = Math.max(1, Math.floor(grantPerCategory / 40000));
const projectFunding = grantPerCategory / numNewProjects;
for (let i = 0; i < numNewProjects; i++) {
const newProject = { id: nextGroupId++, name: `Social Project (Bal) ${nextGroupId - 1}`, balance: projectFunding, initialFunding: projectFunding, contributors: [], purpose: 'social_welfare', impactFactor: 1.0 + Math.random() * 0.5, startYear: currentYear };
groupAccounts.push(newProject);
logEvent(`New Social Project '${newProject.name}' funded with ${formatNumber(projectFunding, 0)}Δ (Balanced)`, "positive");
recordTransaction('GrantSystem', 'SocialProject:' + newProject.name, projectFunding, 0, 'Grant');
}
}
}
});
} else { // Single focus logic
const grantImpactFactor = safeDiv(grantPoolTotal, ioubiUserCount, 0);
if (params.grantFocus === "technology") {
globalStats.techLevel += safeDiv(grantImpactFactor, 100, 0) * (1 + params.innovationPropensity);
} else if (params.grantFocus === "social_welfare") {
agentCohorts.forEach(c => c.wellBeing = Math.min(1, c.wellBeing + safeDiv(grantImpactFactor, 1500, 0)));
} else if (params.grantFocus === "environment" || params.grantFocus === "infrastructure") {
globalStats.techLevel += safeDiv(grantImpactFactor, 800, 0) * 0.5;
agentCohorts.forEach(c => c.wellBeing = Math.min(1, c.wellBeing + safeDiv(grantImpactFactor, 3000, 0)));
} else if (params.grantFocus === "social_projects" && ioubiUserCount > 0) {
const numNewProjects = Math.max(1, Math.floor(grantPoolTotal / 40000));
const projectFunding = grantPoolTotal / numNewProjects;
for (let i = 0; i < numNewProjects; i++) {
const newProject = { id: nextGroupId++, name: `Social Project ${nextGroupId - 1}`, balance: projectFunding, initialFunding: projectFunding, contributors: [], purpose: 'social_welfare', impactFactor: 1.0 + Math.random() * 0.5, startYear: currentYear };
groupAccounts.push(newProject);
logEvent(`New Social Project '${newProject.name}' funded with ${formatNumber(projectFunding, 0)}Δ`, "positive");
recordTransaction('GrantSystem', 'SocialProject:' + newProject.name, projectFunding, 0, 'Grant');
}
}
}
}
groupAccounts = groupAccounts.filter(ga => ga.balance > 1 || (currentYear - (ga.startYear || 0)) < 5);
let innovationFunding = 0;
agentCohorts.forEach(cohort => {
if (cohort.balance > 0 && cohort.actualPop > 0) {
let cohortInnovationInvestment = cohort.balance * cohort.actualPop * params.innovationPropensity * cohort.innovationScore * 0.1;
innovationFunding += cohortInnovationInvestment;
cohort.balance -= cohortInnovationInvestment / cohort.actualPop;
}
});
if (businessCohort.avgBalance > 0 && businessCohort.count > 0) {
let businessInnovationInvestment = businessCohort.avgBalance * businessCohort.count * params.innovationPropensity * businessCohort.innovationScore * 0.1;
innovationFunding += businessInnovationInvestment;
businessCohort.avgBalance -= businessInnovationInvestment / businessCohort.count;
}
const techIncreaseFromRD = safeDiv(innovationFunding, (globalStats.totalPositiveBalances + businessCohort.count * 1000 + 1) * 5, 0);
globalStats.techLevel += techIncreaseFromRD + 0.005;
globalStats.techLevel = Math.max(1, isNaN(globalStats.techLevel) ? 1 : globalStats.techLevel);
if (techIncreaseFromRD > 0.01) {
logEvent(`R&D investment yielded Tech Level increase of ${techIncreaseFromRD.toFixed(3)}`, "info");
}
agentCohorts.forEach(cohort => {
if (cohort.actualPop <=0) return;
if (cohort.balance > cohort.creditLimit * 0.1) {
cohort.creditLimit *= (1.01 + safeDiv(cohort.balance, cohort.creditLimit * 20 + 1, 0) * 0.03);
cohort.socialCreditAvailableToLend += cohort.balance * 0.05;
} else if (cohort.balance < -cohort.creditLimit * 0.8) {
cohort.creditLimit *= 0.99;
cohort.socialCreditAvailableToLend *= 0.95;
}
cohort.creditLimit = Math.max(params.initialPersonalCredit * 0.2, Math.min(params.initialPersonalCredit * 30, cohort.creditLimit));
cohort.creditLimit = isNaN(cohort.creditLimit) ? params.initialPersonalCredit : cohort.creditLimit;
if ((cohort.name === "Middle Income" || cohort.name === "High Income") && cohort.socialCreditAvailableToLend > 10) {
let amountToLend = cohort.socialCreditAvailableToLend * 0.1 * cohort.wellBeing;
let recipientCohort = agentCohorts.find(c => c.name === "Impoverished" && c.actualPop > 0 && c.socialCreditBorrowed < c.creditLimit * 0.2);
if (recipientCohort && amountToLend > 1) {
const actualLentPerPerson = safeDiv(amountToLend, cohort.actualPop,0);
const actualBorrowedPerPerson = safeDiv(amountToLend, recipientCohort.actualPop,0);
cohort.balance -= actualLentPerPerson;
cohort.socialCreditAvailableToLend -= amountToLend;
recipientCohort.balance += actualBorrowedPerPerson;
recipientCohort.socialCreditBorrowed += amountToLend;
if (currentYear % 5 === 0) logEvent(`${cohort.name} lent ${formatNumber(amountToLend,0)}Δ social credit to ${recipientCohort.name}`, "info");
recordTransaction(cohort.name, recipientCohort.name, amountToLend, 0, 'SocialLend');
}
}
if (cohort.socialCreditBorrowed > 0 && cohort.balance > cohort.socialCreditBorrowed * 0.05) {
let repaymentAmount = Math.min(cohort.socialCreditBorrowed * 0.02, cohort.balance * 0.01);
let lenderCohort = agentCohorts.find(c => (c.name === "High Income" || c.name === "Middle Income") && c.actualPop > 0);
if (lenderCohort) {
const actualRepaidPerPerson = safeDiv(repaymentAmount, cohort.actualPop,0);
const actualReceivedPerPerson = safeDiv(repaymentAmount, lenderCohort.actualPop,0);
cohort.balance -= actualRepaidPerPerson;
cohort.socialCreditBorrowed -= repaymentAmount;
lenderCohort.balance += actualReceivedPerPerson;
lenderCohort.socialCreditAvailableToLend += repaymentAmount;
recordTransaction(cohort.name, lenderCohort.name, repaymentAmount, 0, 'SocialRepay');
}
}
if(cohort.socialCreditBorrowed < 0) cohort.socialCreditBorrowed = 0;
cohort.wellBeing = Math.max(0.05, Math.min(1,
0.1 + (globalStats.avgUbi * DAYS_PER_YEAR / (params.initialPersonalCredit*0.5) ) * 0.2 +
safeDiv(cohort.balance, (cohort.creditLimit + Math.abs(cohort.balance) + 1000),0) * 0.5 +
(globalStats.techLevel / 20) * 0.2 +
(1 - globalStats.giniCoefficient) * 0.1
));
cohort.wellBeing = isNaN(cohort.wellBeing) ? 0.1 : cohort.wellBeing;
cohort.balance = isNaN(cohort.balance) ? (-params.initialPersonalCredit * 0.5) : cohort.balance;
});
if (Math.random() < 0.25) triggerRandomEvent(params);
updateUIDisplay(true);
updateAllIndividualCohortStores(); // Ensure store is updated at year end too
renderOrUpdateIndividualStatsChart(); // <--- refresh individual stats chart once per year
nextYearBtn.disabled = false;
const yearProcessingEndTime = performance.now();
const thisYearProcessingTimeMs = yearProcessingEndTime - yearProcessingStartTime;
cumulativeProcessingTimeMs += thisYearProcessingTimeMs;
const totalSeconds = (cumulativeProcessingTimeMs / 1000).toFixed(1);
document.getElementById('total-sim-time-display').textContent = `Total Sim Time: ${totalSeconds}s`;
document.getElementById('total-year-time-display').textContent = `Total Year Time: ${(thisYearProcessingTimeMs / 1000).toFixed(1)}s`;
// Step time for the last step of the year is already set by the loop.
if (currentYear === MAX_YEARS) {
logEvent("SIMULATION COMPLETE!", "critical");
nextYearBtn.disabled = true;
}
}
function triggerRandomEvent(params) {
const events = [
{ name: "Technological Breakthrough", effect: () => { globalStats.techLevel *= (1.05 + Math.random()*0.1); logEvent("Major Technological Breakthrough boosts innovation!", "positive"); }},
{ name: "Global Recession Scare", effect: () => { agentCohorts.forEach(c => c.baseSpendingPerCapita *= (0.85 + Math.random()*0.1)); businessCohort.spendingToIndividuals *=0.9; businessCohort.spendingToBusinesses *=0.9; logEvent("Global Recession Scare reduces consumer confidence and business spending.", "warning"); }},
{ name: "Economic Boom", condition: () => Math.random() < 0.05, effect: () => { agentCohorts.forEach(c => c.baseSpendingPerCapita *= (1.1 + Math.random()*0.1)); businessCohort.spendingToIndividuals *=1.1; businessCohort.spendingToBusinesses *=1.1; logEvent("Economic Boom increases spending across the board!", "positive"); }},
{ name: "Economic Slowdown", condition: () => Math.random() < 0.05, effect: () => { agentCohorts.forEach(c => c.baseSpendingPerCapita *= (0.9 + Math.random()*0.05)); businessCohort.spendingToIndividuals *=0.95; businessCohort.spendingToBusinesses *=0.95; logEvent("Economic Slowdown slightly reduces spending.", "warning"); }},
{ name: "Pandemic Impact", condition: () => Math.random() < 0.02, effect: () => { ioubiUserCount = Math.max(1, ioubiUserCount * (0.95 + Math.random()*0.04)); agentCohorts.forEach(c => c.wellBeing *= (0.7 + Math.random()*0.1)); logEvent("Pandemic hits, affecting population and well-being.", "critical"); }},
{ name: "Renewable Energy Boom", effect: () => { if(params.grantFocus === "environment" || Math.random() < 0.3) globalStats.techLevel += (0.2 + Math.random()*0.3); agentCohorts.forEach(c => c.wellBeing += (0.01+Math.random()*0.01)); logEvent("Renewable Energy Boom! Tech and well-being increase.", "positive"); }},
{ name: "Successful Social Project Launch", effect: () => {
const funding = (globalStats.totalPositiveBalances * 0.0005);
if (funding > 1000 && ioubiUserCount > 0) {
const newProject = {id: nextGroupId++, name: `Event Project ${nextGroupId-1}`, balance: funding, initialFunding: funding, contributors:[], purpose:'social_welfare', impactFactor: 1.2, startYear: currentYear};
groupAccounts.push(newProject);
logEvent(`New community-driven Social Project '${newProject.name}' launched with ${formatNumber(funding,0)}Δ.`, "positive");
}
}},
{ name: "IP Royalty System Surge", condition: () => globalStats.techLevel > 1.5, effect: () => {
const highIncome = agentCohorts.find(c => c.name === "High Income");
const midIncome = agentCohorts.find(c => c.name === "Middle Income");
if ((highIncome || midIncome) && globalStats.totalTransactionVolumeYear > 0) {
const royaltyIncome = globalStats.totalTransactionVolumeYear * (0.001 + Math.random()*0.002);
if ( highIncome && highIncome.actualPop > 0) highIncome.balance += safeDiv(royaltyIncome * 0.7, highIncome.actualPop,0);
if (midIncome && midIncome.actualPop > 0) midIncome.balance += safeDiv(royaltyIncome * 0.3, midIncome.actualPop,0);
if (businessCohort.count > 0) businessCohort.avgBalance += safeDiv(royaltyIncome * 0.2, businessCohort.count, 0);
logEvent(`IP Royalty System generated ${formatNumber(royaltyIncome,0)}Δ in innovation rewards.`, "info");
}
}},
{ name: "Market Correction", condition: () => Math.random() < 0.1, effect: () => { agentCohorts.forEach(c => { if(c.balance > c.creditLimit * 2) c.balance *= (0.9 + Math.random()*0.05); }); businessCohort.avgBalance *= (0.9 + Math.random()*0.05); logEvent("Market correction adjusts high balances downward.", "warning"); }},
{ name: "Increased Investor Confidence", condition: () => globalStats.giniCoefficient < 0.5, effect: () => { businessCohort.creditLimit *= 1.1; businessCohort.avgBalance *= 1.05; logEvent("Investor confidence up due to stability, businesses expand.", "positive"); }}
];
const eventPool = events.filter(e => e.condition ? e.condition() : true);
if ( eventPool.length > 0) {
const randomEvent = eventPool[Math.floor(Math.random() * eventPool.length)];
randomEvent.effect();
}
}
function updateUIDisplay(isEndOfYearUpdate = false) {
globalStats.totalRedMoney = 0;
globalStats.totalPositiveBalances = 0;
globalStats.totalCreditLimitSystem = 0;
let totalWeightedBalance = 0;
let totalSocialCreditActuallyLent = 0;
agentCohorts.forEach(cohort => {
if(cohort.actualPop > 0) {
if (cohort.balance < 0) globalStats.totalRedMoney += Math.abs(cohort.balance) * cohort.actualPop;
else globalStats.totalPositiveBalances += cohort.balance * cohort.actualPop;
totalWeightedBalance += cohort.balance * cohort.actualPop;
globalStats.totalCreditLimitSystem += cohort.creditLimit * cohort.actualPop;
totalSocialCreditActuallyLent += cohort.socialCreditBorrowed;
}
});
if (businessCohort.count > 0) {
if (businessCohort.avgBalance < 0) globalStats.totalRedMoney += Math.abs(businessCohort.avgBalance) * businessCohort.count;
else globalStats.totalPositiveBalances += businessCohort.avgBalance * businessCohort.count;
totalWeightedBalance += businessCohort.avgBalance * businessCohort.count;
globalStats.totalCreditLimitSystem += businessCohort.creditLimit * businessCohort.count;
}
// Update Sanity Check values
const sanityStartTime = performance.now(); // Measure start time for Sanity Check
let sanityPopulation = ioubiUserCount;
let sanityTotalPositive = globalStats.totalPositiveBalances;
let sanityTotalNegative = globalStats.totalRedMoney;
let sanityNetBalance = totalWeightedBalance;
let sanityAvgBalance = sanityPopulation > 0 ? sanityNetBalance / sanityPopulation : 0;
// UBI Distribution Status: difference between vanished fees (for UBI) and UBI distributed so far
// This is a rough proxy for the IOUBI "pending UBI" effect
document.getElementById('sanity-population').textContent = formatNumber(sanityPopulation, 0);
document.getElementById('sanity-avg-balance').textContent = sanityAvgBalance.toFixed(2);
document.getElementById('sanity-total-positive').textContent = formatNumber(sanityTotalPositive, 1);
document.getElementById('sanity-total-negative').textContent = formatNumber(sanityTotalNegative, 1);
document.getElementById('sanity-net-balance').textContent = formatNumber(sanityNetBalance, 1);
// --- FIX: Track vanished fees and distributed UBI on a daily basis, not just annual ---
// Use cumulative values since last reset (start of year)
if (!window._ubiStatusTracker) {
window._ubiStatusTracker = {
vanishedFeesForUBI: 0,
distributedUBI: 0,
lastYear: currentYear
};
}
// Reset tracker at start of each year
if (window._ubiStatusTracker.lastYear !== currentYear) {
window._ubiStatusTracker.vanishedFeesForUBI = 0;
window._ubiStatusTracker.distributedUBI = 0;
window._ubiStatusTracker.lastYear = currentYear;
}
// Add today's vanished fees and distributed UBI
const todayVanishedFeesForUBI = typeof globalStats.dailyPoolRevenue === "number"
? globalStats.dailyPoolRevenue * (parseFloat(paramInputs.transactionFeeSplitUbi.value) / 100)
: 0;
const todayDistributedUBI = typeof globalStats.avgUbi === "number"
? globalStats.avgUbi * ioubiUserCount
: 0;
window._ubiStatusTracker.vanishedFeesForUBI += todayVanishedFeesForUBI;
window._ubiStatusTracker.distributedUBI += todayDistributedUBI;
let sanityUbiStatus = window._ubiStatusTracker.vanishedFeesForUBI - window._ubiStatusTracker.distributedUBI;
document.getElementById('sanity-ubi-status').textContent = formatNumber(sanityUbiStatus, 2);
updateModuleSteptime('module-sanity-check', sanityStartTime); // Update steptime for Sanity Check
globalStats.netSystemDeltars = globalStats.totalPositiveBalances - globalStats.totalRedMoney;
globalStats.socialCreditLent = totalSocialCreditActuallyLent;
if (ioubiUserCount > 0) {
const meanBalance = safeDiv(totalWeightedBalance, ioubiUserCount,0);
let sumOfAbsoluteDifferences = 0;
agentCohorts.forEach(c1 => {
if(c1.actualPop > 0) {
agentCohorts.forEach(c2 => {
if(c2.actualPop > 0) {
sumOfAbsoluteDifferences += Math.abs(c1.balance - c2.balance) * (c1.actualPop/ioubiUserCount) * (c2.actualPop/ioubiUserCount);
}
});
}
});
globalStats.giniCoefficient = safeDiv(sumOfAbsoluteDifferences, 2 * Math.max(1,Math.abs(meanBalance)), globalStats.giniCoefficient);
globalStats.giniCoefficient = Math.max(0.01, Math.min(0.99, isNaN(globalStats.giniCoefficient) ? 0.85 : globalStats.giniCoefficient));
}
const avgWellBeing = agentCohorts.reduce((sum, c) => sum + c.wellBeing * c.popShare, 0);
let populationInPoverty = 0;
agentCohorts.forEach(c => {
if (c.wellBeing < avgWellBeing * 0.6 && c.actualPop > 0) populationInPoverty += c.actualPop;
});
globalStats.povertyRate = safeDiv(populationInPoverty * 100, ioubiUserCount, globalStats.povertyRate);
globalStats.povertyRate = isNaN(globalStats.povertyRate) ? 80 : globalStats.povertyRate;
globalStats.activeSocialProjects = groupAccounts.length;
const metricsStartTime = performance.now(); // Measure start time for Key Global Metrics
Object.keys(metricEls).forEach(key => {
if (globalStats.hasOwnProperty(key) && metricEls[key]) {
let value = globalStats[key];
if (key === 'avgUbi' && !isEndOfYearUpdate) {
value = globalStats.avgUbi;
} else if (key === 'avgUbi' && isEndOfYearUpdate) {
value = globalStats.avgUbi;
}
if (typeof value === 'number') {
if (isNaN(value) || !isFinite(value)) value = 0;
const decimals = (key === 'avgUbi' || key === 'techLevel' || key === 'giniCoefficient') ? 2 :
(key === 'povertyRate' || key === 'ioubiAdoption' || key === 'creditUtilization') ? 1 : 0;
const exactValue = value.toFixed(decimals + 2);
metricEls[key].parentNode.setAttribute('data-tooltip', `Exact: ${exactValue}`);
metricEls[key].textContent = (key === 'totalRedMoney' || key === 'totalPositiveBalances' || key === 'netSystemDeltars' || key === 'annualPoolRevenue' || key === 'socialCreditLent') ? formatNumber(value,1) : value.toFixed(decimals);
} else {
metricEls[key].textContent = "N/A";
}
}
});
metricEls.ioubiAdoption.textContent = safeDiv(ioubiUserCount * 100, GLOBAL_POPULATION, 0).toFixed(1);
let ioubiAdoptionAbsEl = document.getElementById('ioubi-adoption-abs');
if (ioubiAdoptionAbsEl) {
ioubiAdoptionAbsEl.textContent = `(${formatNumber(ioubiUserCount,0)} users)`;
}
// Credit Utilization
globalStats.creditUtilization = safeDiv(globalStats.totalRedMoney * 100, globalStats.totalCreditLimitSystem, 0);
metricEls.creditUtilization.textContent = globalStats.creditUtilization.toFixed(1);
// Pool Revenue: show both annual and latest daily
let dailyPoolRevenue = 0;
if (typeof globalStats.dailyPoolRevenue === "number") dailyPoolRevenue = globalStats.dailyPoolRevenue;
let dailyPoolRevenueEl = document.getElementById('daily-pool-revenue');
if (dailyPoolRevenueEl) {
dailyPoolRevenueEl.textContent = `Daily: ${formatNumber(dailyPoolRevenue,1)}Δ`;
}
updateModuleSteptime('module-metrics', metricsStartTime);
const cohortsTableStartTime = performance.now();
agentCohortsTableBodyEl.innerHTML = '';
agentCohorts.forEach(cohort => {
const row = agentCohortsTableBodyEl.insertRow();
let cohortDisplayName = cohort.name;
if (cohort.name === "Impoverished") cohortDisplayName = "🧑‍🦲 " + cohort.name;
else if (cohort.name === "Low Income") cohortDisplayName = "🧑 " + cohort.name;
else if (cohort.name === "Middle Income") cohortDisplayName = "🧑‍💼 " + cohort.name;
else if (cohort.name === "High Income") cohortDisplayName = "🧐 " + cohort.name;
row.insertCell().textContent = cohortDisplayName;
const actualPopPercent = safeDiv(cohort.actualPop * 100, ioubiUserCount, 0);
const popCell = row.insertCell();
popCell.textContent = `${actualPopPercent.toFixed(1)}% (${formatNumber(cohort.actualPop, 1)})`;
popCell.setAttribute('title', `Exact: ${Math.floor(cohort.actualPop).toLocaleString()} individuals`);
const balanceCell = row.insertCell();
balanceCell.textContent = formatNumber(cohort.balance, 1);
balanceCell.setAttribute('title', `Average balance per person. Positive = savings, Negative = debt.\nExact: ${cohort.balance.toFixed(2)} Δ`);
const creditLimitCell = row.insertCell();
creditLimitCell.textContent = formatNumber(Number(cohort.creditLimit), 1);
creditLimitCell.setAttribute('title', `Average credit limit per person.\nExact: ${Number(cohort.creditLimit).toFixed(2)} Δ`);
const wellBeingCell = row.insertCell();
wellBeingCell.textContent = cohort.wellBeing.toFixed(2);
wellBeingCell.setAttribute('title', `Well-being formula:\n0.1 + (annual UBI / (0.5 × initial credit)) × 0.2\n+ (balance / (credit limit + |balance| + 1000)) × 0.5\n+ (tech level / 20) × 0.2\n+ (1 - gini) × 0.1\nValue: ${cohort.wellBeing.toFixed(4)}`);
const socialCreditCell = row.insertCell();
socialCreditCell.textContent = `${formatNumber(cohort.socialCreditAvailableToLend,0)} / ${formatNumber(cohort.socialCreditBorrowed,0)}`;
socialCreditCell.setAttribute('title', `Available to Lend: ${cohort.socialCreditAvailableToLend.toFixed(2)}Δ (0.2 × avg credit limit)\nBorrowed: ${cohort.socialCreditBorrowed.toFixed(2)}Δ`);
});
updateModuleSteptime('module-cohorts', cohortsTableStartTime);
if (isEndOfYearUpdate) {
const mainTrendsChartStartTime = performance.now();
if (chartData.labels.length >= chartHistoryLimit && currentYear > 0) {
chartData.labels.shift();
chartData.datasets.forEach(dataset => dataset.data.shift());
}
chartData.labels.push(currentYear.toString());
chartData.datasets[0].data.push(isNaN(globalStats.avgUbi)?0:globalStats.avgUbi);
chartData.datasets[1].data.push(isNaN(globalStats.giniCoefficient)?0.85:globalStats.giniCoefficient);
chartData.datasets[2].data.push(safeDiv(ioubiUserCount * 100, GLOBAL_POPULATION, 0));
chartData.datasets[3].data.push(isNaN(globalStats.techLevel)?1:globalStats.techLevel);
if(mainChart) mainChart.update();
updateModuleSteptime('module-main-trends', mainTrendsChartStartTime);
}
// --- Lorenz Curve Calculation with Better Granularity and Tooltip Data ---
const lorenzChartStartTime = performance.now(); // Start timing for Lorenz chart
const sortedCohorts = [...agentCohorts].filter(c => c.actualPop > 0).sort((a, b) => a.balance - b.balance);
let cumulativePop = 0;
let cumulativeWealth = 0;
const totalPopForLorenz = sortedCohorts.reduce((sum, c) => sum + c.actualPop, 0);
let minBalancePoint = 0;
if (sortedCohorts.length > 0) {
minBalancePoint = sortedCohorts.reduce((min, p) => p.balance < min ? p.balance : min, sortedCohorts[0].balance);
}
let wealthOffset = 0;
if (minBalancePoint < 0) {
wealthOffset = -minBalancePoint * totalPopForLorenz;
}
const currentTotalWealth = sortedCohorts.reduce((sum, c) => sum + (c.balance * c.actualPop), 0);
const effectiveTotalWealth = currentTotalWealth + wealthOffset;
// --- Improved Lorenz Curve: 100 points granularity ---
lorenzChartData.datasets[1].data = [];
let lorenzPoints = [];
if (totalPopForLorenz > 0 && effectiveTotalWealth > 0.001) {
// Build a flat array of all individuals' balances (approximate by splitting cohorts)
let individuals = [];
sortedCohorts.forEach(cohort => {
let pop = Math.floor(cohort.actualPop);
let sampleStep = 1;
if (pop > 1000) sampleStep = Math.ceil(pop / 1000);
let samples = Math.floor(pop / sampleStep);
for (let i = 0; i < samples; i++) {
individuals.push(cohort.balance);
}
});
individuals.sort((a, b) => a - b);
const n = individuals.length;
let cumPop = 0;
let cumWealth = 0;
let totalWealth = 0;
// Calculate total effective wealth for normalization
for (let i = 0; i < n; i++) {
totalWealth += individuals[i] - minBalancePoint;
}
// Lorenz points: for each percentile, compute cumulative wealth
for (let i = 0; i <= 100; i++) {
let idx = Math.floor(i * n / 100);
if (idx >= n) idx = n - 1;
let cumWealthAtIdx = 0;
for (let j = 0; j <= idx; j++) {
cumWealthAtIdx += individuals[j] - minBalancePoint;
}
let popFrac = i / 100;
let wealthFrac = totalWealth > 0 ? cumWealthAtIdx / totalWealth : 0;
lorenzPoints.push({x: popFrac * 100, y: wealthFrac * 100});
}
lorenzChartData.datasets[1].data = lorenzPoints;
} else if (totalPopForLorenz > 0) {
lorenzChartData.datasets[1].data = [{x:0, y:0}, {x:100, y:0}];
}
// Always add perfect equality line
lorenzChartData.datasets[0].data = Array.from({length: 101}, (_, i) => ({x: i, y: i}));
if(lorenzChart) {
lorenzChart.options.parsing = false; // Ensure Chart.js uses {x, y} objects
lorenzChart.options.plugins.tooltip = {
enabled: true,
callbacks: {
label: function(context) {
const x = context.parsed.x;
const y = context.parsed.y;
return `Cumulative Pop: ${x.toFixed(1)}%\nCumulative Wealth: ${y.toFixed(2)}%`;
},
afterBody: function(context) {
return [
'',
'The Lorenz curve shows cumulative population vs cumulative wealth.',
'Jumps/straight lines indicate large cohorts or wealth gaps.',
'In IOUBI, cohorts are grouped, so the curve may have steps.',
'Perfect equality would be a diagonal line.'
];
}
}
};
lorenzChart.update();
}
updateModuleSteptime('module-additional-charts', lorenzChartStartTime); // Update steptime for additional charts (Lorenz)
const txLogRenderStartTime = performance.now();
sampledTransactionEl.innerHTML = '';
const logToDisplay = transactionLog.slice(-MAX_TRANSACTION_LOG_DISPLAY);
if (logToDisplay.length === 0) {
sampledTransactionEl.innerHTML = '<p>No transactions logged yet.</p>';
} else {
logToDisplay.reverse().forEach(tx => {
const p = document.createElement('p');
p.innerHTML = `<strong>${tx.timestamp}</strong> [${tx.type}] ID:${tx.id} <br> ${tx.fromEntity} → ${tx.toEntity} <br> Amount: ${formatNumber(tx.amount,1)}Δ, Fee: ${formatNumber(tx.fee,2)}Δ`;
p.className = 'info';
sampledTransactionEl.appendChild(p);
});
}
updateModuleSteptime('module-tx-log', txLogRenderStartTime);
}
// --- Economic Tests ---
function runComprehensiveTests() {
const backupState = {
cohorts: JSON.parse(JSON.stringify(agentCohorts)),
business: JSON.parse(JSON.stringify(businessCohort)),
stats: JSON.parse(JSON.stringify(globalStats)),
users: ioubiUserCount,
groups: JSON.parse(JSON.stringify(groupAccounts)),
txLog: JSON.parse(JSON.stringify(transactionLog)),
year: currentYear,
day: currentDay,
nextTxId: nextTransactionId,
nextGrpId: nextGroupId
};
logEvent("Beginning comprehensive IOUBI economic tests...", "info");
testClosedMoneySystem();
testTransactionFeeCalculation();
testUbiDistribution();
testBalanceSheetIntegrity();
testCreditCreation();
testAverageBalanceCalculation();
testNetSystemDeltarsGrowth();
testMoneyFlowInTransaction();
testP2PCreditSharing();
testSocialCreditProjectImpact();
testIPRoyaltyDistribution();
agentCohorts = backupState.cohorts;
businessCohort = backupState.business;
globalStats = backupState.stats;
ioubiUserCount = backupState.users;
groupAccounts = backupState.groups;
transactionLog = backupState.txLog;
currentYear = backupState.year;
currentDay = backupState.day;
nextTransactionId = backupState.nextTxId;
nextGroupId = backupState.nextGrpId;
updateUIDisplay(true);
logEvent("Economic tests completed. State restored.", "info");
}
function testClosedMoneySystem() {
logEvent("TEST 1: Closed Money System for Transactions", "info");
const tempCohorts = [ { name: "Sender", balance: 1000, actualPop: 100, popShare: 0.5 }, { name: "Receiver", balance: 500, actualPop: 100, popShare: 0.5 } ];
const initialTotalMoney = (tempCohorts[0].balance * tempCohorts[0].actualPop) + (tempCohorts[1].balance * tempCohorts[1].actualPop);
const actingCohort = tempCohorts[0];
const receivingCohort = tempCohorts[1];
const transactionAmount = 10000;
let tempFeesCollected = 0;
const feeRate = getAvgTransactionFeeRate(actingCohort, receivingCohort);
const fees = transactionAmount * (feeRate / 100);
tempFeesCollected += fees;
actingCohort.balance -= safeDiv(transactionAmount, actingCohort.actualPop, 0);
receivingCohort.balance += safeDiv(transactionAmount - fees, receivingCohort.actualPop, 0);
const finalDirectMoney = (tempCohorts[0].balance * tempCohorts[0].actualPop) + (tempCohorts[1].balance * tempCohorts[1].actualPop);
const totalWithFees = finalDirectMoney + tempFeesCollected;
if (Math.abs(initialTotalMoney - totalWithFees) < 0.01) {
logEvent("✓ TEST 1 PASSED: Money conserved in transactions.", "positive");
} else {
logEvent(`✗ TEST 1 FAILED: Money not conserved. Diff: ${(initialTotalMoney - totalWithFees).toFixed(2)}Δ`, "critical");
}
}
function testTransactionFeeCalculation() {
logEvent("TEST 2: Transaction Fee Calculation", "info");
const testBalances = [0, 1, 10, 100, 1000, 10000, -100, -1000];
const expectedFeeComponents = [1.0, 1.0, 1.0, 2.0, 3.0, 4.0, 2.0, 3.0];
let allPassed = true;
for (let i = 0; i < testBalances.length; i++) {
const feeComp = calculateLog10FeeComponent(testBalances[i]);
if (Math.abs(feeComp - expectedFeeComponents[i]) >= 0.01) allPassed = false;
}
const entityA = { balance: 1000, name: "Middle" };
const entityB = { balance: 10000, name: "High" };
const feeRate = getAvgTransactionFeeRate(entityA, entityB);
const expectedRate = (3.0 + 4.0) / 2;
if (Math.abs(feeRate - expectedRate) >= 0.01) allPassed = false;
if (allPassed) logEvent("✓ TEST 2 PASSED: Fee calculations correct.", "positive");
else logEvent("✗ TEST 2 FAILED: Fee calculation issues.", "critical");
}
function testUbiDistribution() {
logEvent("TEST 3: UBI Distribution Mechanics", "info");
const testFeePool = 10000; const testPopulation = 1000; const testDays = 365;
const tempCohorts = [ { name: "Neg", balance: -100, actualPop: 500 }, { name: "Pos", balance: 200, actualPop: 500 } ];
const originalIoubiCount = ioubiUserCount; ioubiUserCount = testPopulation;
let initialTotalRedMoney = Math.abs(tempCohorts[0].balance) * tempCohorts[0].actualPop;
let initialTotalPositiveBalances = tempCohorts[1].balance * tempCohorts[1].actualPop;
const initialNetDeltars = initialTotalPositiveBalances - initialTotalRedMoney;
const ubiPoolTotal = testFeePool * 0.75; // Assuming 75% split
const dailyUbiPerUser = safeDiv(ubiPoolTotal, ioubiUserCount, 0);
tempCohorts.forEach(cohort => { if (cohort.actualPop > 0) cohort.balance += dailyUbiPerUser * testDays; });
let finalTotalRedMoney = 0; let finalTotalPositiveBalances = 0;
tempCohorts.forEach(cohort => { if (cohort.balance < 0) finalTotalRedMoney += Math.abs(cohort.balance) * cohort.actualPop; else finalTotalPositiveBalances += cohort.balance * cohort.actualPop; });
const finalNetDeltars = finalTotalPositiveBalances - finalTotalRedMoney;
const netDeltarsChange = finalNetDeltars - initialNetDeltars;
const totalUbiInjected = dailyUbiPerUser * testDays * testPopulation;
if (Math.abs(netDeltarsChange - totalUbiInjected) < 0.01) {
logEvent("✓ TEST 3 PASSED: UBI correctly increases Net System Deltars.", "positive");
} else {
logEvent(`✗ TEST 3 FAILED: UBI impact incorrect. Diff: ${(netDeltarsChange - totalUbiInjected).toFixed(2)}Δ`, "critical");
}
ioubiUserCount = originalIoubiCount;
}
function testBalanceSheetIntegrity() {
logEvent("TEST 4: Balance Sheet Integrity", "info");
const tempCohorts = [ { name: "Neg1", balance: -100, actualPop: 100 }, { name: "Neg2", balance: -200, actualPop: 50 }, { name: "Pos1", balance: 300, actualPop: 80 }, { name: "Pos2", balance: 500, actualPop: 30 } ];
let expectedRedMoney = 0; let expectedPositive = 0;
tempCohorts.forEach(c => { if (c.balance < 0) expectedRedMoney += Math.abs(c.balance) * c.actualPop; else expectedPositive += c.balance * c.actualPop; });
let calculatedRed = 0; let calculatedPositive = 0;
tempCohorts.forEach(c => { if(c.actualPop > 0) { if (c.balance < 0) calculatedRed += Math.abs(c.balance) * c.actualPop; else calculatedPositive += c.balance * c.actualPop; } });
if (Math.abs(calculatedRed - expectedRedMoney) < 0.01 && Math.abs(calculatedPositive - expectedPositive) < 0.01) {
logEvent("✓ TEST 4 PASSED: Balance sheet calculations correct.", "positive");
} else {
logEvent(`✗ TEST 4 FAILED: Balance calculations incorrect.`, "critical");
}
}
function testCreditCreation() {
logEvent("TEST 5: Credit Creation and Adoption", "info");
const initialPersonalCredit = 200; const newUserCount = 1000;
const expectedInitialDebt = initialPersonalCredit * 0.5 * newUserCount;
const tempCohort = { name: "Test", actualPop: 0, balance: 0, creditLimit: 0 };
const oldPop = tempCohort.actualPop; const newToCohort = newUserCount;
tempCohort.actualPop += newToCohort;
const weightedOldBalance = tempCohort.balance * oldPop;
const newUsersInitialDebt = initialPersonalCredit * 0.5 * newToCohort;
tempCohort.balance = safeDiv(weightedOldBalance - newUsersInitialDebt, tempCohort.actualPop, tempCohort.balance);
const totalDebtCreated = -tempCohort.balance * tempCohort.actualPop;
if (Math.abs(totalDebtCreated - expectedInitialDebt) < 0.01) {
logEvent("✓ TEST 5 PASSED: Credit creation during adoption correct.", "positive");
} else {
logEvent(`✗ TEST 5 FAILED: Credit creation incorrect. Diff: ${(totalDebtCreated - expectedInitialDebt).toFixed(2)}Δ`, "critical");
}
}
function testAverageBalanceCalculation() {
logEvent("TEST 6: Average Balance Calculation", "info");
const tempCohorts = [ { name: "Rich", balance: 0, actualPop: 100, totalBalance: 500000 }, { name: "Mid", balance: 0, actualPop: 1000, totalBalance: 1000000 }, { name: "Poor", balance: 0, actualPop: 500, totalBalance: -100000 } ];
let allPassed = true;
tempCohorts.forEach(c => {
c.balance = safeDiv(c.totalBalance, c.actualPop, 0); // Calculate avg balance first
if (Math.abs((c.balance * c.actualPop) - c.totalBalance) >= 0.01) allPassed = false;
});
if (allPassed) logEvent("✓ TEST 6 PASSED: Average balance calculations work.", "positive");
else logEvent("✗ TEST 6 FAILED: Average balance issues.", "critical");
}
function testNetSystemDeltarsGrowth() {
const testPopulation = 1000;
logEvent("TEST 7: Net System Deltars Growth", "info");
const originalIoubiCount = ioubiUserCount; const originalStats = {...globalStats};
ioubiUserCount = 1000;
const tempCohorts = [ { name: "Neg", balance: -100, actualPop: 500 }, { name: "Pos", balance: 100, actualPop: 500 } ];
let initialNet = (tempCohorts[1].balance * tempCohorts[1].actualPop) - (Math.abs(tempCohorts[0].balance) * tempCohorts[0].actualPop);
const ubiPool = 10000 * 0.75; // Simulate 10k fees, 75% UBI
const ubiPerPerson = safeDiv(ubiPool, ioubiUserCount, 0);
tempCohorts.forEach(c => c.balance += ubiPerPerson);
let finalNet = 0; tempCohorts.forEach(c => finalNet += c.balance * c.actualPop);
let netGrowth = finalNet - initialNet;
const totalUbiInjected = ubiPerPerson * testPopulation;
if (Math.abs(netGrowth - totalUbiInjected) < 0.01) {
logEvent("✓ TEST 7 PASSED: Net System Deltars increased approx by UBI amount.", "positive");
} else {
logEvent(`✗ TEST 7 FAILED: Net Deltars growth differs from UBI. Diff: ${(netGrowth - totalUbiInjected).toFixed(2)}Δ`, "critical");
}
ioubiUserCount = originalIoubiCount; globalStats = originalStats;
}
function testMoneyFlowInTransaction() {
logEvent("TEST 8: Money Flow Conservation in Transactions", "info");
const sender = { balance: 500, actualPop: 100 }; const receiver = { balance: 200, actualPop: 200 };
const initialTotalMoney = (sender.balance * sender.actualPop) + (receiver.balance * receiver.actualPop);
const transactionAmount = 10000; const fees = transactionAmount * (2.0 / 100); // 2% fee
sender.balance -= safeDiv(transactionAmount, sender.actualPop, 0);
receiver.balance += safeDiv(transactionAmount - fees, receiver.actualPop, 0);
const finalDirectMoney = (sender.balance * sender.actualPop) + (receiver.balance * receiver.actualPop);
const finalTotalWithFees = finalDirectMoney + fees;
let passA = Math.abs(initialTotalMoney - finalTotalWithFees) < 0.01;
// Simulate UBI distribution of these specific fees back to these two entities for test simplicity
const totalPopForTest = sender.actualPop + receiver.actualPop;
const ubiPerPersonForTest = safeDiv(fees, totalPopForTest, 0);
sender.balance += ubiPerPersonForTest;
receiver.balance += ubiPerPersonForTest;
const ubiTotalMoney = (sender.balance * sender.actualPop) + (receiver.balance * receiver.actualPop);
let passB = Math.abs(initialTotalMoney - ubiTotalMoney) < 0.01;
if (passA && passB) logEvent("✓ TEST 8 PASSED: Money conserved pre and post UBI.", "positive");
else logEvent(`✗ TEST 8 FAILED: Conservation issue (Pre-UBI: ${passA}, Post-UBI: ${passB})`, "critical");
}
function testP2PCreditSharing() {
logEvent("TEST 9: P2P Credit Sharing", "info");
const tempLender = { balance: 10000, actualPop: 10, socialCreditAvailableToLend: 1000, wellBeing: 0.8 };
const tempBorrower = { balance: -100, actualPop: 10, socialCreditBorrowed: 0 };
let amountToLend = tempLender.socialCreditAvailableToLend * 0.1 * tempLender.wellBeing;
const initialLenderBalance = tempLender.balance; const initialBorrowerBalance = tempBorrower.balance;
tempLender.balance -= safeDiv(amountToLend, tempLender.actualPop, 0);
tempLender.socialCreditAvailableToLend -= amountToLend;
tempBorrower.balance += safeDiv(amountToLend, tempBorrower.actualPop, 0);
tempBorrower.socialCreditBorrowed += amountToLend;
if (tempLender.balance < initialLenderBalance && tempBorrower.balance > initialBorrowerBalance && tempBorrower.socialCreditBorrowed > 0) {
logEvent("✓ TEST 9 PASSED: P2P credit sharing transfers funds and updates vars.", "positive");
} else {
logEvent("✗ TEST 9 FAILED: P2P credit sharing issue.", "critical");
}
}
function testSocialCreditProjectImpact() {
logEvent("TEST 10: Social Credit Project Impact", "info");
const originalTechLevel = globalStats.techLevel;
const tempProject = {balance:100000, initialFunding:100000, purpose:'technology', impactFactor:1.5 };
for (let i=0; i<5; i++) {
if (tempProject.balance > 0) {
let dailyProjectSpending = Math.min(tempProject.balance, tempProject.initialFunding / (30 * 2)); // Assume 30 days/year for test
tempProject.balance -= dailyProjectSpending;
if ( tempProject.purpose === 'technology') globalStats.techLevel += dailyProjectSpending * 0.0001 * tempProject.impactFactor;
else if (tempProject.purpose === 'social_welfare') agentCohorts.forEach(c => { if(c.actualPop > 0) c.wellBeing = Math.min(1, c.wellBeing + dailyProjectSpending * 0.00001 * tempProject.impactFactor / c.actualPop)});
}
}
if (globalStats.techLevel > originalTechLevel && tempProject.balance < 100000) {
logEvent("✓ TEST 10 PASSED: Social project spending impacts target.", "positive");
} else {
logEvent("✗ TEST 10 FAILED: Social project impact issue.", "critical");
}
globalStats.techLevel = originalTechLevel;
}
function testIPRoyaltyDistribution() {
logEvent("TEST 11: IP Royalty Distribution", "info");
const tempHighIncome = { name: "High Income", balance: 10000, actualPop: 10 };
const originalBalance = tempHighIncome.balance;
const originalTechLevel = globalStats.techLevel; const originalTransactionVolume = globalStats.totalTransactionVolumeYear;
globalStats.techLevel = 2.0; globalStats.totalTransactionVolumeYear = 1000000;
const royaltyIncome = globalStats.totalTransactionVolumeYear * 0.0015;
tempHighIncome.balance += safeDiv(royaltyIncome, tempHighIncome.actualPop, 0);
if (tempHighIncome.balance > originalBalance) {
logEvent("✓ TEST 11 PASSED: IP Royalty distribution increases balance.", "positive");
} else {
logEvent("✗ TEST 11 FAILED: IP Royalty distribution issue.", "critical");
}
globalStats.techLevel = originalTechLevel; globalStats.totalTransactionVolumeYear = originalTransactionVolume;
}
// --- Event Listeners & Initial Setup ---
paramInputs.daysInYear.addEventListener('input', (e) => {
DAYS_PER_YEAR = parseInt(e.target.value) || 365;
if (DAYS_PER_YEAR < 12) DAYS_PER_YEAR = 12;
if (DAYS_PER_YEAR > 365) DAYS_PER_YEAR = 365;
paramInputs.daysInYear.value = DAYS_PER_YEAR;
daysPerYearDisplayEl.textContent = DAYS_PER_YEAR;
});
nextYearBtn.addEventListener('click', simulateYear);
if (runTestsBtn) { // Add event listener for the test button
runTestsBtn.addEventListener('click', runComprehensiveTests);
}
// paramInputs.yearlyTimeBudget.addEventListener('input', (e) => {
// paramValueDisplays.yearlyTimeBudget.textContent = `${e.target.value}s`;
// });
// paramValueDisplays.yearlyTimeBudget.textContent = `${paramInputs.yearlyTimeBudget.value}s`;
Object.keys(paramValueDisplays).forEach(key => {
if (paramInputs[key] && (paramInputs[key].type === 'range')) {
paramInputs[key].addEventListener('input', (e) => {
let displayValue = parseFloat(e.target.value);
let text = "";
if (key === 'baseAdoptionRate' || key === 'transactionFeeSplitUbi') {
text = `${displayValue.toFixed(1)}%`;
} else if (key === 'yearlyTimeBudget') {
text = `${displayValue.toFixed(0)}s`;
} else if (key === 'innovationInvestmentPropensity') {
text = displayValue.toFixed(3);
} else if (key === 'avgVelocityMultiplier' || key === 'redMoneyTolerance') {
text = displayValue.toFixed(2);
} else {
text = displayValue.toFixed(1); // Default for other potential sliders
}
paramValueDisplays[key].textContent = text;
});
// Set initial display value
let initialDisplayValue = parseFloat(paramInputs[key].value);
let initialText = "";
if (key === 'baseAdoptionRate' || key === 'transactionFeeSplitUbi') {
initialText = `${initialDisplayValue.toFixed(1)}%`;
} else if (key === 'yearlyTimeBudget') {
initialText = `${initialDisplayValue.toFixed(0)}s`;
} else if (key === 'innovationInvestmentPropensity') {
initialText = initialDisplayValue.toFixed(3);
} else if (key === 'avgVelocityMultiplier' || key === 'redMoneyTolerance') {
initialText = initialDisplayValue.toFixed(2);
} else {
initialText = initialDisplayValue.toFixed(1); // Default for other potential sliders
}
paramValueDisplays[key].textContent = initialText;
}
});
// --- Collapsible & Draggable Module Logic ---
const modules = Array.from(dashboardMainEl.querySelectorAll('.dashboard-module'));
function saveModuleStates() {
const moduleOrder = Array.from(dashboardMainEl.children)
.filter(child => child.classList.contains('dashboard-module'))
.map(module => module.id);
localStorage.setItem('ioubiDashboardModuleOrder', JSON.stringify(moduleOrder));
const collapsedStates = {};
modules.forEach(module => {
collapsedStates[module.id] = module.classList.contains('collapsed');
});
localStorage.setItem('ioubiDashboardModuleCollapsedStates', JSON.stringify(collapsedStates));
}
function loadModuleStates() {
// Check if either key is missing; if so, save defaults and return
const savedOrderJson = localStorage.getItem('ioubiDashboardModuleOrder');
const savedStatesJson = localStorage.getItem('ioubiDashboardModuleCollapsedStates');
if (!savedOrderJson || !savedStatesJson) {
saveModuleStates();
return;
}
let needToSaveDefaults = false;
try {
const savedOrder = JSON.parse(savedOrderJson);
if (Array.isArray(savedOrder)) {
savedOrder.forEach(moduleId => {
const moduleEl = document.getElementById(moduleId);
if (moduleEl) {
dashboardMainEl.appendChild(moduleEl);
} else {
needToSaveDefaults = true;
}
});
} else {
needToSaveDefaults = true;
}
} catch (e) {
needToSaveDefaults = true;
}
const currentModulesInDOM = Array.from(dashboardMainEl.querySelectorAll('.dashboard-module'));
modules.splice(0, modules.length, ...currentModulesInDOM);
try {
const savedCollapsedStates = JSON.parse(savedStatesJson);
if (savedCollapsedStates && typeof savedCollapsedStates === 'object') {
modules.forEach(module => {
if (savedCollapsedStates.hasOwnProperty(module.id)) {
if (savedCollapsedStates[module.id]) {
module.classList.add('collapsed');
const icon = module.querySelector('.toggle-collapse path');
if (icon) icon.setAttribute('d', 'M12 8c.55 0 1 .45 1 1v2h2c.55 0 1 .45 1 1s-.45 1-1 1h-2v2c0 .55-.45 1-1 1s-1-.45-1-1v-2H9c-.55 0-1-.45-1-1s.45-1 1-1h2V9c0-.55.45-1 1-1z'); // Plus
} else {
module.classList.remove('collapsed');
const icon = module.querySelector('.toggle-collapse path');
if (icon) icon.setAttribute('d', 'M19 13H5v-2h14v2z'); // Minus
}
} else {
needToSaveDefaults = true;
}
});
} else {
needToSaveDefaults = true;
}
} catch (e) {
needToSaveDefaults = true;
}
if (needToSaveDefaults) {
saveModuleStates();
}
}
modules.forEach(module => {
const titleBar = module.querySelector('.module-title-bar');
const toggleButton = module.querySelector('.toggle-collapse');
if (toggleButton) {
toggleButton.addEventListener('click', () => {
module.classList.toggle('collapsed');
const isCollapsed = module.classList.contains('collapsed');
const iconPath = toggleButton.querySelector('path');
if (isCollapsed) {
iconPath.setAttribute('d', 'M12 8c.55 0 1 .45 1 1v2h2c.55 0 1 .45 1 1s-.45 1-1 1h-2v2c0 .55-.45 1-1 1s-1-.45-1-1v-2H9c-.55 0-1-.45-1-1s.45-1 1-1h2V9c0-.55.45-1 1-1z'); // Plus
} else {
iconPath.setAttribute('d', 'M19 13H5v-2h14v2z'); // Minus
}
saveModuleStates();
});
}
if (titleBar) {
titleBar.addEventListener('dragstart', (e) => {
module.classList.add('is-dragging'); // Apply to the parent module
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', module.id); // Use parent module's ID
});
titleBar.addEventListener('dragend', (e) => {
module.classList.remove('is-dragging'); // Remove from parent module
const placeholder = dashboardMainEl.querySelector('.dragging-placeholder');
if (placeholder) {
placeholder.remove();
}
saveModuleStates();
});
}
});
dashboardMainEl.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const draggingModule = dashboardMainEl.querySelector('.is-dragging');
if (!draggingModule) return;
const afterElement = getDragAfterElement(dashboardMainEl, e.clientY);
let placeholder = dashboardMainEl.querySelector('.dragging-placeholder');
if (!placeholder) {
placeholder = document.createElement('div');
placeholder.classList.add('dragging-placeholder');
}
if (afterElement == null) {
dashboardMainEl.appendChild(placeholder);
} else {
dashboardMainEl.insertBefore(placeholder, afterElement);
}
});
dashboardMainEl.addEventListener('drop', (e) => {
e.preventDefault();
const id = e.dataTransfer.getData('text/plain');
const draggable = document.getElementById(id); // This should now get the correct module
const placeholder = dashboardMainEl.querySelector('.dragging-placeholder');
if (draggable && placeholder) {
dashboardMainEl.insertBefore(draggable, placeholder);
placeholder.remove();
}
if(draggable) draggable.classList.remove('is-dragging'); // Ensure class is removed
saveModuleStates();
});
function getDragAfterElement(container, y) {
const draggableElements = [...container.querySelectorAll('.dashboard-module:not(.is-dragging):not(.dragging-placeholder)')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
initCharts();
loadModuleStates();
populateIndividualStatsDropdowns(); // Populate dropdowns before first render
// Dynamically add steptime spans to ALL modules
const allDashboardModules = dashboardMainEl.querySelectorAll('.dashboard-module');
allDashboardModules.forEach(moduleEl => {
const titleBar = moduleEl.querySelector('.module-title-bar');
const h3 = titleBar ? titleBar.querySelector('h3') : null;
if (h3) {
// Check if a steptime span already exists to prevent duplicates
if (!h3.nextElementSibling || !h3.nextElementSibling.classList.contains('module-steptime')) {
const steptimeSpan = document.createElement('span');
steptimeSpan.classList.add('module-steptime');
// steptimeSpan.textContent = '(0 ms)'; // Initial text
h3.insertAdjacentElement('afterend', steptimeSpan);
}
}
});
updateUIDisplay(true);
logEvent("Welcome! Adjust parameters and advance years. Interactive UI enabled.", "info");
enableFloatingTooltips();
// Initialize and set up listeners for the new individual stats chart
if (individualCohortSelectEl && individualTimeframeSelectEl && individualLogRetentionSelectEl) {
// Initialize stores for all cohorts defined in the select dropdown
for (let i = 0; i < individualCohortSelectEl.options.length; i++) {
initializeIndividualCohortStoreEntry(individualCohortSelectEl.options[i].value);
}
renderOrUpdateIndividualStatsChart(); // Initial render
individualCohortSelectEl.addEventListener('change', renderOrUpdateIndividualStatsChart);
individualTimeframeSelectEl.addEventListener('change', renderOrUpdateIndividualStatsChart);
individualLogRetentionSelectEl.addEventListener('change', renderOrUpdateIndividualStatsChart);
}
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment