This commit is contained in:
2026-03-14 21:31:12 +01:00
commit 3c83dfb07e
9 changed files with 2375 additions and 0 deletions

BIN
ayto/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

660
ayto/index.html Normal file
View File

@@ -0,0 +1,660 @@
<!DOCTYPE html>
<html lang="en" class="h-full" data-theme="mocha">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Are You The One? Calculator</title>
<link rel="icon" href="/ayto/favicon.ico" type="image/x-icon">
<!-- Load Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Use Inter font -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root, [data-theme="mocha"] {
--crust: #11111b;
--mantle: #181825;
--base: #1e1e2e;
--surface0: #313244;
--surface1: #45475a;
--surface2: #585b70;
--overlay0: #6c7086;
--overlay1: #7f849c;
--overlay2: #9399b2;
--subtext0: #a6adc8;
--subtext1: #bac2de;
--text: #cdd6f4;
--lavender: #b4befe;
--blue: #89b4fa;
--sapphire: #74c7ec;
--sky: #89dceb;
--teal: #94e2d5;
--green: #a6e3a1;
--yellow: #f9e2af;
--peach: #fab387;
--maroon: #eba0ac;
--red: #f38ba8;
--mauve: #cba6f7;
--pink: #f5c2e7;
--flamingo: #f2cdcd;
--rosewater: #f5e0dc;
}
[data-theme="latte"] {
--crust: #dce0e8;
--mantle: #e6e9ef;
--base: #eff1f5;
--surface0: #ccd0da;
--surface1: #bcc0cc;
--surface2: #acb0be;
--overlay0: #9ca0b0;
--overlay1: #8c8fa1;
--overlay2: #7c7f93;
--subtext0: #6c6f85;
--subtext1: #5c5f77;
--text: #4c4f69;
--lavender: #7287fd;
--blue: #1e66f5;
--sapphire: #209fb5;
--sky: #04a5e5;
--teal: #179299;
--green: #40a02b;
--yellow: #df8e1d;
--peach: #fe640b;
--maroon: #e64553;
--red: #d20f39;
--mauve: #8839ef;
--pink: #ea76cb;
--flamingo: #dd7878;
--rosewater: #dc8a78;
}
[data-theme="frappe"] {
--crust: #232634;
--mantle: #292c3c;
--base: #303446;
--surface0: #414559;
--surface1: #51576d;
--surface2: #626880;
--overlay0: #737994;
--overlay1: #838ba7;
--overlay2: #949cbb;
--subtext0: #a5adce;
--subtext1: #b5bfe2;
--text: #c6d0f5;
--lavender: #babbf1;
--blue: #8caaee;
--sapphire: #85c1dc;
--sky: #99d1db;
--teal: #81c8be;
--green: #a6d189;
--yellow: #e5c890;
--peach: #ef9f76;
--maroon: #ea999c;
--red: #e78284;
--mauve: #ca9ee6;
--pink: #f4b8e4;
--flamingo: #eebebe;
--rosewater: #f2d5cf;
}
[data-theme="macchiato"] {
--crust: #181926;
--mantle: #1e2030;
--base: #24273a;
--surface0: #363a4f;
--surface1: #494d64;
--surface2: #5b6078;
--overlay0: #6e738d;
--overlay1: #8087a2;
--overlay2: #939ab7;
--subtext0: #a5adcb;
--subtext1: #b8c0e0;
--text: #cad3f5;
--lavender: #b7bdf8;
--blue: #8aadf4;
--sapphire: #7dc4e4;
--sky: #91d7e3;
--teal: #8bd5ca;
--green: #a6da95;
--yellow: #eed49f;
--peach: #f5a97f;
--maroon: #ee99a0;
--red: #ed8796;
--mauve: #c6a0f6;
--pink: #f5bde6;
--flamingo: #f0c6c6;
--rosewater: #f4dbd6;
}
body {
font-family: 'Inter', sans-serif;
scroll-behavior: smooth;
}
.table-container::-webkit-scrollbar { width: 6px; height: 6px; }
.table-container::-webkit-scrollbar-thumb { background-color: var(--overlay1); border-radius: 3px; }
.table-container::-webkit-scrollbar-track { background-color: var(--mantle); }
.prob-100 { background-color: var(--green); color: var(--base); font-weight: bold; }
.prob-0 { background-color: var(--red); color: var(--base); }
.prob-possible { background-color: var(--peach); color: var(--base); }
</style>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
rosewater: 'var(--rosewater)',
flamingo: 'var(--flamingo)',
pink: 'var(--pink)',
mauve: 'var(--mauve)',
red: 'var(--red)',
maroon: 'var(--maroon)',
peach: 'var(--peach)',
yellow: 'var(--yellow)',
green: 'var(--green)',
teal: 'var(--teal)',
sky: 'var(--sky)',
sapphire: 'var(--sapphire)',
blue: 'var(--blue)',
lavender: 'var(--lavender)',
text: 'var(--text)',
subtext1: 'var(--subtext1)',
subtext0: 'var(--subtext0)',
overlay2: 'var(--overlay2)',
overlay1: 'var(--overlay1)',
overlay0: 'var(--overlay0)',
surface2: 'var(--surface2)',
surface1: 'var(--surface1)',
surface0: 'var(--surface0)',
base: 'var(--base)',
mantle: 'var(--mantle)',
crust: 'var(--crust)',
}
}
}
}
</script>
</head>
<body class="bg-base text-text min-h-full">
<!-- Theme Switcher -->
<div class="w-full bg-mantle border-b border-surface0 p-2 flex justify-end items-center pr-4">
<label for="theme-select" class="text-sm font-medium text-subtext1 mr-2">Theme:</label>
<select id="theme-select" class="p-1 bg-surface1 border border-surface2 rounded text-text text-sm focus:ring-blue focus:border-blue">
<option value="mocha">Mocha</option>
<option value="macchiato">Macchiato</option>
<option value="frappe">Frappé</option>
<option value="latte">Latte</option>
</select>
</div>
<!-- Main Container -->
<div class="max-w-7xl mx-auto p-4 sm:p-8">
<!-- Header -->
<header class="text-center mb-8">
<h1 class="text-4xl font-bold text-lavender mb-2">Are You The One? Calculator</h1>
<p class="text-lg text-subtext1">Find the remaining possibilities and probabilities for your season.</p>
</header>
<!-- Error Message Popup -->
<div id="error-message" class="hidden fixed top-5 right-5 bg-red text-base p-4 rounded-md shadow-lg z-50 transition-all duration-300 ease-out opacity-0">
<span id="error-text"></span>
</div>
<!-- Loading Spinner -->
<div id="loading-spinner" class="hidden fixed inset-0 bg-base bg-opacity-75 flex items-center justify-center z-50">
<div class="animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-blue"></div>
<p class="ml-4 text-text text-lg">Calculating with Rust Backend...</p>
</div>
<!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Left Column: Inputs -->
<div class="flex flex-col gap-8">
<!-- Section 1: Setup Contestants -->
<section id="setup-section" class="bg-surface1 p-6 rounded-lg shadow-md">
<h2 class="text-2xl font-semibold mb-4 pb-2 border-b border-surface2 text-mauve">1. Setup Contestants</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="group1-names" class="block text-sm font-medium text-subtext1 mb-1">Group 1 (Matchers)</label>
<textarea id="group1-names" rows="10" class="w-full p-3 bg-surface0 border border-surface2 rounded-md text-text placeholder-subtext0 focus:ring-blue focus:border-blue" placeholder="Enter one name per line...
(Must be <= Group 2)
Jake
Brett
..."></textarea>
</div>
<div>
<label for="group2-names" class="block text-sm font-medium text-subtext1 mb-1">Group 2 (Pool)</label>
<textarea id="group2-names" rows="10" class="w-full p-3 bg-surface0 border border-surface2 rounded-md text-text placeholder-subtext0 focus:ring-blue focus:border-blue" placeholder="Enter one name per line...
(Must be >= Group 1)
Jenna
Kayla
..."></textarea>
</div>
</div>
<button id="setup-button" class="mt-4 w-full bg-blue hover:bg-sapphire text-base font-bold py-3 px-4 rounded-md shadow-lg transition-all duration-200 ease-in-out">
Set Contestants
</button>
<!-- Save/Load Buttons -->
<div class="mt-4 grid grid-cols-2 gap-4">
<button id="save-button" class="w-full bg-surface2 hover:bg-overlay0 text-subtext1 font-bold py-2 px-4 rounded-md shadow-lg transition-all duration-200 ease-in-out" disabled>
Save Configuration
</button>
<button id="load-button" class="w-full bg-surface2 hover:bg-overlay0 text-subtext1 font-bold py-2 px-4 rounded-md shadow-lg transition-all duration-200 ease-in-out">
Load Configuration
</button>
</div>
<!-- Hidden file input for loading -->
<input type="file" id="file-loader" class="hidden" accept=".json">
<p id="initial-possibilities-text" class="text-center mt-3 text-subtext0 text-sm italic"></p>
</section>
<!-- Section 2: Truth Booths -->
<section id="truth-booth-section" class="bg-surface1 p-6 rounded-lg shadow-md hidden">
<h2 class="text-2xl font-semibold mb-4 pb-2 border-b border-surface2 text-mauve">2. Add Truth Booths</h2>
<form id="truth-booth-form" class="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end">
<div>
<label for="tb-group1" class="block text-sm font-medium text-subtext1 mb-1">Group 1</label>
<select id="tb-group1" class="w-full p-3 bg-surface0 border border-surface2 rounded-md text-text focus:ring-blue focus:border-blue"></select>
</div>
<div>
<label for="tb-group2" class="block text-sm font-medium text-subtext1 mb-1">Group 2</label>
<select id="tb-group2" class="w-full p-3 bg-surface0 border border-surface2 rounded-md text-text focus:ring-blue focus:border-blue"></select>
</div>
<div>
<label for="tb-result" class="block text-sm font-medium text-subtext1 mb-1">Result</label>
<select id="tb-result" class="w-full p-3 bg-surface0 border border-surface2 rounded-md text-text focus:ring-blue focus:border-blue">
<option value="no-match">No Match</option>
<option value="match">Perfect Match</option>
</select>
</div>
<button type="submit" class="sm:col-span-3 w-full bg-green hover:bg-teal text-base font-bold py-3 px-4 rounded-md shadow-lg transition-all duration-200 ease-in-out">
Add Truth Booth Result
</button>
</form>
<div id="truth-booth-list" class="mt-4 space-y-2"></div>
</section>
<!-- Section 3: Matchup Ceremonies -->
<section id="ceremony-section" class="bg-surface1 p-6 rounded-lg shadow-md hidden">
<h2 class="text-2xl font-semibold mb-4 pb-2 border-b border-surface2 text-mauve">3. Add Matchup Ceremonies</h2>
<form id="ceremony-form">
<p class="text-subtext1 mb-4">Set the pairs for this ceremony. Ensure each person from Group 2 is selected at most once.</p>
<div id="ceremony-pairs-container" class="space-y-3 mb-4"></div>
<div class="flex items-center gap-4">
<label for="ceremony-beams" class="block text-sm font-medium text-subtext1 whitespace-nowrap">Correct Beams:</label>
<input type="number" id="ceremony-beams" min="0" class="w-full p-3 bg-surface0 border border-surface2 rounded-md text-text placeholder-subtext0 focus:ring-blue focus:border-blue" placeholder="e.g., 3">
</div>
<button type="submit" class="mt-4 w-full bg-mauve hover:bg-pink text-base font-bold py-3 px-4 rounded-md shadow-lg transition-all duration-200 ease-in-out">
Add Ceremony Result
</button>
</form>
<div id="ceremony-list" class="mt-4 space-y-2"></div>
</section>
</div>
<!-- Right Column: Results -->
<div class="flex flex-col gap-8">
<!-- Section 4: Calculate -->
<section id="calculate-section" class="bg-surface1 p-6 rounded-lg shadow-md hidden sticky top-8">
<h2 class="text-2xl font-semibold mb-4 pb-2 border-b border-surface2 text-mauve">4. Calculate Results</h2>
<button id="calculate-button" class="w-full bg-blue hover:bg-sapphire text-base font-bold py-3 px-4 rounded-md shadow-lg transition-all duration-200 ease-in-out">
Calculate Probabilities
</button>
<div id="results-display" class="mt-6 text-center hidden">
<h3 class="text-lg font-medium text-subtext1">Remaining Possibilities:</h3>
<p id="total-possibilities" class="text-5xl font-bold text-text my-2">--</p>
</div>
</section>
<!-- Section 5: Probability Grid -->
<section id="grid-section" class="bg-surface1 p-6 rounded-lg shadow-md hidden">
<h2 class="text-2xl font-semibold mb-4 pb-2 border-b border-surface2 text-mauve">Probability Grid</h2>
<p class="text-subtext1 mb-4 text-sm">This table shows the probability of each pair being a "Perfect Match" based on the remaining possibilities.</p>
<div id="probability-grid-container" class="table-container overflow-x-auto rounded-lg border border-surface2">
<table id="probability-table" class="w-full text-sm text-center">
<thead id="probability-table-head" class="bg-surface0 text-subtext1 uppercase tracking-wider"></thead>
<tbody id="probability-table-body" class="bg-surface1 divide-y divide-surface2"></tbody>
</table>
</div>
</section>
</div>
</div>
</div>
<script type="module">
let group1Names = [];
let group2Names = [];
let truthBooths = [];
let ceremonies = [];
const $ = (selector) => document.getElementById(selector);
const setupButton = $("setup-button");
const calculateButton = $("calculate-button");
const saveButton = $("save-button");
const loadButton = $("load-button");
const fileLoader = $("file-loader");
const themeSelect = $("theme-select");
const g1NamesText = $("group1-names");
const g2NamesText = $("group2-names");
const tbSection = $("truth-booth-section");
const tbForm = $("truth-booth-form");
const tbGroup1 = $("tb-group1");
const tbGroup2 = $("tb-group2");
const tbResult = $("tb-result");
const tbList = $("truth-booth-list");
const ceremonySection = $("ceremony-section");
const ceremonyForm = $("ceremony-form");
const ceremonyPairsContainer = $("ceremony-pairs-container");
const ceremonyBeams = $("ceremony-beams");
const ceremonyList = $("ceremony-list");
const calculateSection = $("calculate-section");
const gridSection = $("grid-section");
const resultsDisplay = $("results-display");
const totalPossibilitiesText = $("total-possibilities");
const probTableHead = $("probability-table-head");
const probTableBody = $("probability-table-body");
const errorMessage = $("error-message");
const errorText = $("error-text");
const loadingSpinner = $("loading-spinner");
const initialPossibilitiesText = $("initial-possibilities-text");
// Theme Handling
themeSelect.addEventListener('change', (e) => {
document.documentElement.setAttribute('data-theme', e.target.value);
localStorage.setItem('ayto-theme', e.target.value);
});
// Load saved theme
const savedTheme = localStorage.getItem('ayto-theme');
if (savedTheme) {
document.documentElement.setAttribute('data-theme', savedTheme);
themeSelect.value = savedTheme;
}
function showLoading() { loadingSpinner.classList.remove("hidden"); }
function hideLoading() { loadingSpinner.classList.add("hidden"); }
function showError(message) {
errorText.textContent = message;
errorMessage.classList.remove("hidden", "opacity-0");
setTimeout(() => {
errorMessage.classList.add("opacity-0");
setTimeout(() => errorMessage.classList.add("hidden"), 3000);
}, 3000);
}
function handleSetupContestants() {
const g1 = g1NamesText.value.split('\n').map(s => s.trim()).filter(s => s.length > 0);
const g2 = g2NamesText.value.split('\n').map(s => s.trim()).filter(s => s.length > 0);
if (g1.length === 0 || g2.length === 0) {
showError("Please enter names for both groups.");
return false;
}
if (g1.length > g2.length) {
showError("Group 1 must have fewer or an equal number of members as Group 2.");
return false;
}
if (new Set(g1).size !== g1.length || new Set(g2).size !== g2.length) {
showError("Duplicate names are not allowed within a group.");
return false;
}
group1Names = g1;
group2Names = g2;
truthBooths = [];
ceremonies = [];
tbList.innerHTML = '';
ceremonyList.innerHTML = '';
populateSelectors();
populateCeremonyPairBuilder();
initialPossibilitiesText.textContent = `Calculator ready for ${group1Names.length} (G1) and ${group2Names.length} (G2) contestants.`;
tbSection.classList.remove("hidden");
ceremonySection.classList.remove("hidden");
calculateSection.classList.remove("hidden");
gridSection.classList.remove("hidden");
resultsDisplay.classList.add("hidden");
totalPossibilitiesText.textContent = '--';
probTableHead.innerHTML = '';
probTableBody.innerHTML = '';
saveButton.disabled = false;
return true;
}
function populateSelectors() {
tbGroup1.innerHTML = group1Names.map(name => `<option value="${name}">${name}</option>`).join('');
tbGroup2.innerHTML = group2Names.map(name => `<option value="${name}">${name}</option>`).join('');
}
function populateCeremonyPairBuilder() {
ceremonyPairsContainer.innerHTML = group1Names.map(name => `
<div class="grid grid-cols-3 gap-2 items-center">
<label class="text-subtext1 text-right col-span-1">${name}</label>
<span class="text-overlay0 text-center col-auto">-</span>
<select data-g1-name="${name}" class="ceremony-pair-select w-full p-2 bg-surface0 border border-surface2 rounded-md text-text col-span-1 focus:ring-blue focus:border-blue">
<option value="">Select match...</option>
${group2Names.map(g2name => `<option value="${g2name}">${g2name}</option>`).join('')}
</select>
</div>
`).join('');
}
function renderTruthBoothUI(booth) {
const el = document.createElement('div');
el.className = `p-3 rounded-md flex justify-between items-center text-base ${booth.isMatch ? 'bg-green' : 'bg-red'}`;
el.innerHTML = `
<span class="text-base"><strong>${booth.p1}</strong> & <strong>${booth.p2}</strong> = ${booth.isMatch ? 'PERFECT MATCH' : 'NO MATCH'}</span>
<button data-id="${booth.id}" class="remove-tb-btn text-surface0 hover:text-crust text-2xl leading-none font-bold">&times;</button>
`;
tbList.appendChild(el);
el.querySelector('.remove-tb-btn').addEventListener('click', handleRemoveTruthBooth);
}
function handleAddTruthBooth(e) {
e.preventDefault();
const booth = { id: Date.now(), p1: tbGroup1.value, p2: tbGroup2.value, isMatch: tbResult.value === 'match' };
truthBooths.push(booth);
renderTruthBoothUI(booth);
tbForm.reset();
}
function handleRemoveTruthBooth(e) {
const idToRemove = Number(e.target.dataset.id);
truthBooths = truthBooths.filter(b => b.id !== idToRemove);
e.target.parentElement.remove();
}
function renderCeremonyUI(ceremony, index) {
const el = document.createElement('div');
el.className = 'p-3 rounded-md bg-surface2 ceremony-item';
el.innerHTML = `
<div class="flex justify-between items-center">
<span class="font-semibold text-mauve"><strong>Ceremony ${index}</strong>: ${ceremony.beams} Beam${ceremony.beams === 1 ? '' : 's'}</span>
<button data-id="${ceremony.id}" class="remove-ceremony-btn text-red hover:text-maroon text-2xl leading-none font-bold">&times;</button>
</div>
<div class="text-sm text-subtext0 mt-2 pl-4">
${Object.entries(ceremony.pairs).map(([p1, p2]) => `<div>${p1} - ${p2}</div>`).join('')}
</div>
`;
ceremonyList.appendChild(el);
el.querySelector('.remove-ceremony-btn').addEventListener('click', handleRemoveCeremony);
}
function handleAddCeremony(e) {
e.preventDefault();
const pairSelectors = document.querySelectorAll('.ceremony-pair-select');
const pairs = {};
const selectedG2Names = [];
for (const select of pairSelectors) {
const g1Name = select.dataset.g1Name;
const g2Name = select.value;
if (!g2Name) return showError(`Please select a match for ${g1Name}.`);
selectedG2Names.push(g2Name);
pairs[g1Name] = g2Name;
}
if (new Set(selectedG2Names).size !== selectedG2Names.length) {
return showError("Each person from Group 2 can only be matched once per ceremony.");
}
const beams = parseInt(ceremonyBeams.value, 10);
if (isNaN(beams) || beams < 0 || beams > group1Names.length) {
return showError(`Beams must be a number between 0 and ${group1Names.length}.`);
}
const ceremony = { id: Date.now(), pairs, beams };
ceremonies.push(ceremony);
renderCeremonyUI(ceremony, ceremonies.length);
ceremonyForm.reset();
}
function handleRemoveCeremony(e) {
const idToRemove = Number(e.target.dataset.id);
ceremonies = ceremonies.filter(c => c.id !== idToRemove);
e.target.closest('.ceremony-item').remove();
ceremonyList.innerHTML = '';
ceremonies.forEach((c, i) => renderCeremonyUI(c, i + 1));
}
async function handleCalculate() {
showLoading();
try {
const payload = {
group1: group1Names,
group2: group2Names,
truth_booths: truthBooths.map(tb => ({ p1: tb.p1, p2: tb.p2, match_: tb.isMatch })),
ceremonies: ceremonies.map(c => ({ pairs: c.pairs, beams: c.beams }))
};
const res = await fetch('/api/solve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
throw new Error(`Server error: ${res.statusText}`);
}
const results = await res.json();
totalPossibilitiesText.textContent = results.possibilities.toLocaleString();
resultsDisplay.classList.remove("hidden");
if (results.possibilities === 0) {
showError("Impossible scenario. The data entered contradicts itself.");
probTableBody.innerHTML = '';
probTableHead.innerHTML = '';
} else {
displayProbabilityGrid(results.grid_data);
}
} catch (e) {
console.error(e);
showError("An error occurred connecting to the backend API.");
} finally {
hideLoading();
}
}
function displayProbabilityGrid(gridData) {
probTableHead.innerHTML = `
<tr class="sticky top-0 bg-surface0 z-10">
<th class="p-3"></th>
${gridData.columns.map(name => `<th class="p-3 whitespace-nowrap">${name}</th>`).join('')}
</tr>
`;
probTableBody.innerHTML = gridData.index.map((g1Name, rowIndex) => `
<tr class="hover:bg-surface2">
<th class="p-3 whitespace-nowrap bg-surface0 sticky left-0 z-5">${g1Name}</th>
${gridData.columns.map((g2Name, colIndex) => {
const prob = gridData.data[rowIndex][colIndex] * 100;
let cellClass = 'prob-possible';
if (prob === 100) cellClass = 'prob-100';
else if (prob === 0) cellClass = 'prob-0';
return `<td class="${cellClass} p-3">${prob.toFixed(1)}%</td>`;
}).join('')}
</tr>
`).join('');
}
function handleSaveConfig() {
if (group1Names.length === 0) return showError("Please set contestants before saving.");
const state = { group1Names, group2Names, truthBooths, ceremonies };
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'ayto-save.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function handleLoadConfig() { fileLoader.click(); }
function handleFileSelected(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try { loadState(JSON.parse(event.target.result)); }
catch (err) { showError("Failed to load file."); }
};
reader.readAsText(file);
e.target.value = null;
}
function loadState(state) {
if (!state.group1Names || !state.group2Names || !state.truthBooths || !state.ceremonies) {
return showError("Invalid save file. Required fields are missing.");
}
g1NamesText.value = state.group1Names.join('\n');
g2NamesText.value = state.group2Names.join('\n');
if (!handleSetupContestants()) return;
truthBooths = state.truthBooths;
ceremonies = state.ceremonies;
truthBooths.forEach(renderTruthBoothUI);
ceremonies.forEach((c, i) => renderCeremonyUI(c, i + 1));
handleCalculate();
}
setupButton.addEventListener("click", handleSetupContestants);
tbForm.addEventListener("submit", handleAddTruthBooth);
ceremonyForm.addEventListener("submit", handleAddCeremony);
calculateButton.addEventListener("click", handleCalculate);
saveButton.addEventListener("click", handleSaveConfig);
loadButton.addEventListener("click", handleLoadConfig);
fileLoader.addEventListener("change", handleFileSelected);
</script>
</body>
</html>

698
ayto/index.html.bak Normal file
View File

@@ -0,0 +1,698 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Are You The One? Calculator (Catppuccin)</title>
<!-- Load Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Use Inter font -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Load Pyodide (Python in the browser) -->
<script src="https://cdn.jsdelivr.net/pyodide/v0.25.1/full/pyodide.js"></script>
<!-- Configure Tailwind with Catppuccin Mocha colors -->
<script>
tailwind.config = {
theme: {
extend: {
colors: {
'rosewater': '#f5e0dc',
'flamingo': '#f2cdcd',
'pink': '#f5c2e7',
'mauve': '#cba6f7',
'red': '#f38ba8',
'maroon': '#eba0ac',
'peach': '#fab387',
'yellow': '#f9e2af',
'green': '#a6e3a1',
'teal': '#94e2d5',
'sky': '#89dceb',
'sapphire': '#74c7ec',
'blue': '#89b4fa',
'lavender': '#b4befe',
'text': '#cdd6f4',
'subtext1': '#bac2de',
'subtext0': '#a6adc8',
'overlay2': '#9399b2',
'overlay1': '#7f849c',
'overlay0': '#6c7086',
'surface2': '#585b70',
'surface1': '#45475a',
'surface0': '#313244',
'base': '#1e1e2e',
'mantle': '#181825',
'crust': '#11111b',
}
}
}
}
</script>
<style>
body {
font-family: 'Inter', sans-serif;
scroll-behavior: smooth;
}
/* Custom scrollbar for probability table */
.table-container::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.table-container::-webkit-scrollbar-thumb {
background-color: #7f849c; /* overlay1 */
border-radius: 3px;
}
.table-container::-webkit-scrollbar-track {
background-color: #181825; /* mantle */
}
/* Style for 100% match */
.prob-100 {
background-color: #a6e3a1; /* green */
color: #1e1e2e; /* base */
font-weight: bold;
}
/* Style for 0% match */
.prob-0 {
background-color: #f38ba8; /* red */
color: #1e1e2e; /* base */
}
/* Style for possible match */
.prob-possible {
background-color: #fab387; /* peach */
color: #1e1e2e; /* base */
}
/* Style for Pyodide loading status */
#pyodide-status {
background-color: #181825; /* mantle */
border-bottom: 1px solid #313244; /* surface0 */
position: sticky;
top: 0;
z-index: 100;
}
</style>
</head>
<body class="bg-base text-text min-h-full">
<!-- Pyodide Loading Status Bar -->
<div id="pyodide-status" class="w-full text-center p-2">
<p id="pyodide-status-text" class="text-subtext1 text-sm">Loading Python runtime...</p>
</div>
<!-- Main Container -->
<div class="max-w-7xl mx-auto p-4 sm:p-8">
<!-- Header -->
<header class="text-center mb-8">
<h1 class="text-4xl font-bold text-lavender mb-2">Are You The One? Calculator</h1>
<p class="text-lg text-subtext1">Find the remaining possibilities and probabilities for your season.</p>
</header>
<!-- Error Message Popup -->
<div id="error-message" class="hidden fixed top-5 right-5 bg-red text-base p-4 rounded-md shadow-lg z-50 transition-all duration-300 ease-out opacity-0">
<span id="error-text"></span>
</div>
<!-- Loading Spinner (for calculation) -->
<div id="loading-spinner" class="hidden fixed inset-0 bg-base bg-opacity-75 flex items-center justify-center z-50">
<div class="animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-blue"></div>
<p class="ml-4 text-text text-lg">Calculating with Python...</p>
</div>
<!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Left Column: Inputs -->
<div class="flex flex-col gap-8">
<!-- Section 1: Setup Contestants -->
<section id="setup-section" class="bg-surface1 p-6 rounded-lg shadow-md">
<h2 class="text-2xl font-semibold mb-4 pb-2 border-b border-surface2 text-mauve">1. Setup Contestants</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="group1-names" class="block text-sm font-medium text-subtext1 mb-1">Group 1 (Matchers)</label>
<textarea id="group1-names" rows="10" class="w-full p-3 bg-surface0 border border-surface2 rounded-md text-text placeholder-subtext0 focus:ring-blue focus:border-blue" placeholder="Enter one name per line...
(Must be <= Group 2)
Jake
Brett
Ryan
..."></textarea>
</div>
<div>
<label for="group2-names" class="block text-sm font-medium text-subtext1 mb-1">Group 2 (Pool)</label>
<textarea id="group2-names" rows="10" class="w-full p-3 bg-surface0 border border-surface2 rounded-md text-text placeholder-subtext0 focus:ring-blue focus:border-blue" placeholder="Enter one name per line...
(Must be >= Group 1)
Jenna
Kayla
Amber
..."></textarea>
</div>
</div>
<button id="setup-button" class="mt-4 w-full bg-blue hover:bg-sapphire text-base font-bold py-3 px-4 rounded-md shadow-lg transition-all duration-200 ease-in-out" disabled>
Set Contestants
</button>
<p id="initial-possibilities-text" class="text-center mt-3 text-subtext0 text-sm italic"></p>
</section>
<!-- Section 2: Truth Booths -->
<section id="truth-booth-section" class="bg-surface1 p-6 rounded-lg shadow-md hidden">
<h2 class="text-2xl font-semibold mb-4 pb-2 border-b border-surface2 text-mauve">2. Add Truth Booths</h2>
<form id="truth-booth-form" class="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end">
<div>
<label for="tb-group1" class="block text-sm font-medium text-subtext1 mb-1">Group 1</label>
<select id="tb-group1" class="w-full p-3 bg-surface0 border border-surface2 rounded-md text-text focus:ring-blue focus:border-blue"></select>
</div>
<div>
<label for="tb-group2" class="block text-sm font-medium text-subtext1 mb-1">Group 2</label>
<select id="tb-group2" class="w-full p-3 bg-surface0 border border-surface2 rounded-md text-text focus:ring-blue focus:border-blue"></select>
</div>
<div>
<label for="tb-result" class="block text-sm font-medium text-subtext1 mb-1">Result</label>
<select id="tb-result" class="w-full p-3 bg-surface0 border border-surface2 rounded-md text-text focus:ring-blue focus:border-blue">
<option value="no-match">No Match</option>
<option value="match">Perfect Match</option>
</select>
</div>
<button type="submit" class="sm:col-span-3 w-full bg-green hover:bg-teal text-base font-bold py-3 px-4 rounded-md shadow-lg transition-all duration-200 ease-in-out">
Add Truth Booth Result
</button>
</form>
<div id="truth-booth-list" class="mt-4 space-y-2">
<!-- Truth booth results will be dynamically added here -->
</div>
</section>
<!-- Section 3: Matchup Ceremonies -->
<section id="ceremony-section" class="bg-surface1 p-6 rounded-lg shadow-md hidden">
<h2 class="text-2xl font-semibold mb-4 pb-2 border-b border-surface2 text-mauve">3. Add Matchup Ceremonies</h2>
<form id="ceremony-form">
<p class="text-subtext1 mb-4">Set the pairs for this ceremony. Ensure each person from Group 2 is selected at most once.</p>
<div id="ceremony-pairs-container" class="space-y-3 mb-4">
<!-- Ceremony pair selectors will be dynamically added here -->
</div>
<div class="flex items-center gap-4">
<label for="ceremony-beams" class="block text-sm font-medium text-subtext1 whitespace-nowrap">Correct Beams:</label>
<input type="number" id="ceremony-beams" min="0" class="w-full p-3 bg-surface0 border border-surface2 rounded-md text-text placeholder-subtext0 focus:ring-blue focus:border-blue" placeholder="e.g., 3">
</div>
<button type="submit" class="mt-4 w-full bg-mauve hover:bg-pink text-base font-bold py-3 px-4 rounded-md shadow-lg transition-all duration-200 ease-in-out">
Add Ceremony Result
</button>
</form>
<div id="ceremony-list" class="mt-4 space-y-2">
<!-- Ceremony results will be dynamically added here -->
</div>
</section>
</div>
<!-- Right Column: Results -->
<div class="flex flex-col gap-8">
<!-- Section 4: Calculate -->
<section id="calculate-section" class="bg-surface1 p-6 rounded-lg shadow-md hidden sticky top-8">
<h2 class="text-2xl font-semibold mb-4 pb-2 border-b border-surface2 text-mauve">4. Calculate Results</h2>
<button id="calculate-button" class="w-full bg-blue hover:bg-sapphire text-base font-bold py-3 px-4 rounded-md shadow-lg transition-all duration-200 ease-in-out">
Calculate Probabilities
</button>
<div id="results-display" class="mt-6 text-center hidden">
<h3 class="text-lg font-medium text-subtext1">Remaining Possibilities:</h3>
<p id="total-possibilities" class="text-5xl font-bold text-text my-2">--</p>
</div>
</section>
<!-- Section 5: Probability Grid -->
<section id="grid-section" class="bg-surface1 p-6 rounded-lg shadow-md hidden">
<h2 class="text-2xl font-semibold mb-4 pb-2 border-b border-surface2 text-mauve">Probability Grid</h2>
<p class="text-subtext1 mb-4 text-sm">This table shows the probability of each pair being a "Perfect Match" based on the remaining possibilities.</p>
<div id="probability-grid-container" class="table-container overflow-x-auto rounded-lg border border-surface2">
<table id="probability-table" class="w-full text-sm text-center">
<thead id="probability-table-head" class="bg-surface0 text-subtext1 uppercase tracking-wider">
<!-- Header row will be dynamically generated -->
</thead>
<tbody id="probability-table-body" class="bg-surface1 divide-y divide-surface2">
<!-- Data rows will be dynamically generated -->
</tbody>
</table>
</div>
</section>
</div>
</div>
</div>
<script type="module">
// --- STATE VARIABLES ---
let group1Names = [];
let group2Names = [];
let truthBooths = [];
let ceremonies = [];
let pyodide = null; // Will hold the Pyodide instance
let pyodideSolve = null; // Will hold the Python solve function
// --- DOM ELEMENTS ---
const $ = (selector) => document.getElementById(selector);
const setupButton = $("setup-button");
const calculateButton = $("calculate-button");
const g1NamesText = $("group1-names");
const g2NamesText = $("group2-names");
const tbSection = $("truth-booth-section");
const tbForm = $("truth-booth-form");
const tbGroup1 = $("tb-group1");
const tbGroup2 = $("tb-group2");
const tbResult = $("tb-result");
const tbList = $("truth-booth-list");
const ceremonySection = $("ceremony-section");
const ceremonyForm = $("ceremony-form");
const ceremonyPairsContainer = $("ceremony-pairs-container");
const ceremonyBeams = $("ceremony-beams");
const ceremonyList = $("ceremony-list");
const calculateSection = $("calculate-section");
const gridSection = $("grid-section");
const resultsDisplay = $("results-display");
const totalPossibilitiesText = $("total-possibilities");
const probTableHead = $("probability-table-head");
const probTableBody = $("probability-table-body");
const errorMessage = $("error-message");
const errorText = $("error-text");
const loadingSpinner = $("loading-spinner");
const initialPossibilitiesText = $("initial-possibilities-text");
const pyodideStatusText = $("pyodide-status-text");
// --- CORE LOGIC ---
/**
* Shows a spinning loader
*/
function showLoading() {
loadingSpinner.classList.remove("hidden");
}
/**
* Hides the spinning loader
*/
function hideLoading() {
loadingSpinner.classList.add("hidden");
}
/**
* Displays an error message popup
* @param {string} message - The error message to display
*/
function showError(message) {
errorText.textContent = message;
errorMessage.classList.remove("hidden");
errorMessage.classList.remove("opacity-0");
setTimeout(() => {
errorMessage.classList.add("opacity-0");
// Wait for transition to finish before hiding
setTimeout(() => errorMessage.classList.add("hidden"), 3000);
}, 3000);
}
/**
* Initializes Pyodide, installs 'ayto', and defines the Python solver function.
*/
async function initPyodide() {
try {
pyodideStatusText.textContent = "Loading Python runtime...";
pyodide = await loadPyodide();
pyodideStatusText.textContent = "Loading 'micropip' (Python package installer)...";
await pyodide.loadPackage("micropip");
// Explicitly import micropip into the global scope after loading
pyodide.runPython("import micropip");
// Get a handle to the now-initialized micropip
const micropip = pyodide.globals.get("micropip");
pyodideStatusText.textContent = "Installing 'ayto' library...";
// The library is called 'ayto' on PyPI
await micropip.install("ayto");
pyodideStatusText.textContent = "Defining Python solver function...";
// Define the Python function that will do the hard work
await pyodide.runPythonAsync(`
from ayto import AYTO
import pandas as pd
import json
def solve(g1_names, g2_names, truth_booths, ceremonies):
calculator = AYTO(g1_names, g2_names)
for booth in truth_booths:
# 'calc_probs' defaults to True, no need to pass
calculator.apply_truth_booth(booth['p1'], booth['p2'], booth['match'])
for ceremony in ceremonies:
# Convert ceremony pairs from dict to list of tuples
matchup_list = list(ceremony['pairs'].items())
calculator.apply_matchup_ceremony(matchup_list, ceremony['beams'])
# Note: No need to call calculate(), as apply_ methods do it.
num_possibilities = calculator.num_scenarios
prob_df = calculator.probabilities
# Convert DataFrame to a JSON string for easy return
prob_json = prob_df.to_json(orient='split')
return json.dumps({
"possibilities": num_possibilities,
"grid_json": prob_json
})
`);
// Get a reference to the Python function
pyodideSolve = pyodide.globals.get('solve');
// Clean up micropip handle
micropip.destroy();
pyodideStatusText.textContent = "Python ready. You may now set contestants.";
setupButton.disabled = false;
setupButton.textContent = "Set Contestants & Prepare Calculator";
} catch (err) {
console.error(err);
pyodideStatusText.textContent = "Error loading Python environment. Please refresh.";
showError("Failed to load Python. Check console for details.");
}
}
/**
* Parses contestant names and validates them.
*/
function handleSetupContestants() {
try {
const g1 = g1NamesText.value.split('\n')
.map(s => s.trim())
.filter(s => s.length > 0);
const g2 = g2NamesText.value.split('\n')
.map(s => s.trim())
.filter(s => s.length > 0);
if (g1.length === 0 || g2.length === 0) {
showError("Please enter names for both groups.");
return;
}
if (g1.length > g2.length) {
showError("Group 1 must have fewer or an equal number of members as Group 2.");
return;
}
// Check for duplicate names
if (new Set(g1).size !== g1.length || new Set(g2).size !== g2.length) {
showError("Duplicate names are not allowed within a group.");
return;
}
group1Names = g1;
group2Names = g2;
// Reset state
truthBooths = [];
ceremonies = [];
tbList.innerHTML = '';
ceremonyList.innerHTML = '';
// Update UI
populateSelectors();
populateCeremonyPairBuilder();
initialPossibilitiesText.textContent = `Calculator ready for ${group1Names.length} (G1) and ${group2Names.length} (G2) contestants.`;
// Show hidden sections
tbSection.classList.remove("hidden");
ceremonySection.classList.remove("hidden");
calculateSection.classList.remove("hidden");
gridSection.classList.remove("hidden");
resultsDisplay.classList.add("hidden"); // Hide results until first calculation
totalPossibilitiesText.textContent = '--';
probTableHead.innerHTML = '';
probTableBody.innerHTML = '';
} catch (e) {
console.error(e);
showError("An unexpected error occurred during setup.");
}
}
/**
* Populates all dropdown selectors with contestant names.
*/
function populateSelectors() {
// Populate Truth Booth selectors
tbGroup1.innerHTML = group1Names.map(name => `<option value="${name}">${name}</option>`).join('');
tbGroup2.innerHTML = group2Names.map(name => `<option value="${name}">${name}</option>`).join('');
}
/**
* Creates the dynamic form for setting ceremony pairs.
*/
function populateCeremonyPairBuilder() {
ceremonyPairsContainer.innerHTML = group1Names.map(name => `
<div class="grid grid-cols-3 gap-2 items-center">
<label class="text-subtext1 text-right col-span-1">${name}</label>
<span class="text-overlay0 text-center col-auto">-</span>
<select data-g1-name="${name}" class="ceremony-pair-select w-full p-2 bg-surface0 border border-surface2 rounded-md text-text col-span-1 focus:ring-blue focus:border-blue">
<option value="">Select match...</option>
${group2Names.map(g2name => `<option value="${g2name}">${g2name}</option>`).join('')}
</select>
</div>
`).join('');
}
/**
* Handles the "Add Truth Booth" form submission.
*/
function handleAddTruthBooth(e) {
e.preventDefault();
const p1 = tbGroup1.value;
const p2 = tbGroup2.value;
const isMatch = tbResult.value === 'match';
// Note: 'isMatch' is the local JS variable name.
// It will be passed to Python as 'match' in handleCalculate.
const booth = { id: Date.now(), p1, p2, isMatch };
truthBooths.push(booth);
// Update UI
const el = document.createElement('div');
el.className = `p-3 rounded-md flex justify-between items-center text-base ${isMatch ? 'bg-green' : 'bg-red'}`;
el.innerHTML = `
<span><strong>${p1}</strong> & <strong>${p2}</strong> = ${isMatch ? 'PERFECT MATCH' : 'NO MATCH'}</span>
<button data-id="${booth.id}" class="remove-tb-btn text-surface0 hover:text-crust text-2xl leading-none font-bold">&times;</button>
`;
tbList.appendChild(el);
el.querySelector('.remove-tb-btn').addEventListener('click', handleRemoveTruthBooth);
tbForm.reset();
}
/**
* Removes a Truth Booth from the list
*/
function handleRemoveTruthBooth(e) {
const idToRemove = Number(e.target.dataset.id);
truthBooths = truthBooths.filter(b => b.id !== idToRemove);
e.target.parentElement.remove();
}
/**
* Handles the "Add Ceremony" form submission.
*/
function handleAddCeremony(e) {
e.preventDefault();
const pairSelectors = document.querySelectorAll('.ceremony-pair-select');
const pairs = {}; // This is a JS dict, will be converted to list of tuples for Python
const selectedG2Names = [];
for (const select of pairSelectors) {
const g1Name = select.dataset.g1Name;
const g2Name = select.value;
if (!g2Name) {
showError(`Please select a match for ${g1Name}.`);
return;
}
selectedG2Names.push(g2Name);
pairs[g1Name] = g2Name;
}
if (new Set(selectedG2Names).size !== selectedG2Names.length) {
showError("Each person from Group 2 can only be matched once per ceremony.");
return;
}
const beams = parseInt(ceremonyBeams.value, 10);
if (isNaN(beams) || beams < 0 || beams > group1Names.length) {
showError(`Beams must be a number between 0 and ${group1Names.length}.`);
return;
}
const ceremony = { id: Date.now(), pairs, beams };
ceremonies.push(ceremony);
// Update UI
const el = document.createElement('div');
el.className = 'p-3 rounded-md bg-surface2 ceremony-item';
el.innerHTML = `
<div class="flex justify-between items-center">
<span class="font-semibold text-mauve"><strong>Ceremony ${ceremonies.length}</strong>: ${beams} Beam${beams === 1 ? '' : 's'}</span>
<button data-id="${ceremony.id}" class="remove-ceremony-btn text-red hover:text-maroon text-2xl leading-none font-bold">&times;</button>
</div>
<div class="text-sm text-subtext0 mt-2 pl-4">
${Object.entries(pairs).map(([p1, p2]) => `<div>${p1} - ${p2}</div>`).join('')}
</div>
`;
ceremonyList.appendChild(el);
el.querySelector('.remove-ceremony-btn').addEventListener('click', handleRemoveCeremony);
ceremonyForm.reset();
}
/**
* Removes a Ceremony from the list
*/
function handleRemoveCeremony(e) {
const idToRemove = Number(e.target.dataset.id);
ceremonies = ceremonies.filter(c => c.id !== idToRemove);
e.target.closest('.ceremony-item').remove();
}
/**
* The main calculation function. Passes data to Python and gets results.
*/
async function handleCalculate() {
if (!pyodideSolve) {
showError("Python environment is not ready. Please wait.");
return;
}
showLoading();
// Use a timeout to allow the spinner to render before the (blocking) Python call
setTimeout(async () => {
let g1_py, g2_py, tb_py, c_py;
let result_json_py;
let results;
try {
// 1. Convert JS data to Pyodide (Python) proxies
g1_py = pyodide.toPy(group1Names);
g2_py = pyodide.toPy(group2Names);
const simpleTruthBooths = truthBooths.map(tb => ({
p1: tb.p1,
p2: tb.p2,
match: tb.isMatch
}));
const simpleCeremonies = ceremonies.map(c => ({
pairs: c.pairs,
beams: c.beams
}));
tb_py = pyodide.toPy(simpleTruthBooths);
c_py = pyodide.toPy(simpleCeremonies);
// 2. Call the Python 'solve' function
// result_json_py will be a native JS string
result_json_py = await pyodideSolve(g1_py, g2_py, tb_py, c_py);
// 3. Parse the JS string directly, no .toJs()
results = JSON.parse(result_json_py);
const grid_data = JSON.parse(results.grid_json);
// 4. Update Results Display
totalPossibilitiesText.textContent = results.possibilities.toLocaleString();
resultsDisplay.classList.remove("hidden");
// 5. Calculate and display probabilities
displayProbabilityGrid(grid_data);
} catch (e) {
console.error(e);
// Check for the impossible scenario error
if (e.message && e.message.includes("Impossible scenario provided")) {
showError("Impossible scenario. The data entered contradicts itself.");
totalPossibilitiesText.textContent = '0';
resultsDisplay.classList.remove("hidden");
probTableBody.innerHTML = '';
probTableHead.innerHTML = '';
} else {
showError("An error occurred during Python calculation. Check console.");
}
} finally {
// 6. Clean up Pyodide proxies to free memory
g1_py?.destroy();
g2_py?.destroy();
tb_py?.destroy();
c_py?.destroy();
// result_json_py is a JS string, it has no .destroy() method.
hideLoading();
}
}, 10);
}
/**
* Renders the probability grid from the Python (pandas) data.
* @param {object} gridData - Parsed JSON from pandas DataFrame (orient='split')
*/
function displayProbabilityGrid(gridData) {
// gridData has { index: [g1_names], columns: [g2_names], data: [[...], [...]] }
// 1. Render Table Header
probTableHead.innerHTML = `
<tr class="sticky top-0 bg-surface0 z-10">
<th class="p-3"></th>
${gridData.columns.map(name => `<th class="p-3 whitespace-nowrap">${name}</th>`).join('')}
</tr>
`;
// 2. Render Table Body
probTableBody.innerHTML = gridData.index.map((g1Name, rowIndex) => `
<tr class="hover:bg-surface2">
<th class="p-3 whitespace-nowrap bg-surface0 sticky left-0 z-5">${g1Name}</th>
${gridData.columns.map((g2Name, colIndex) => {
// The 'data' from pandas is the probability (0.0 to 1.0)
const prob = gridData.data[rowIndex][colIndex] * 100; // Convert prob to percentage
let cellClass = 'prob-possible';
if (prob === 100) cellClass = 'prob-100';
else if (prob === 0) cellClass = 'prob-0';
return `<td class="${cellClass} p-3">${prob.toFixed(1)}%</td>`;
}).join('')}
</tr>
`).join('');
}
// --- EVENT LISTENERS ---
setupButton.addEventListener("click", handleSetupContestants);
tbForm.addEventListener("submit", handleAddTruthBooth);
ceremonyForm.addEventListener("submit", handleAddCeremony);
calculateButton.addEventListener("click", handleCalculate);
// --- START PYODIDE ---
initPyodide();
</script>
</body>
</html>