// =============================================================================
// 1. DATA (with Corrected Colors)
// =============================================================================
solutes = [
{
"group": "Soluble Ionic Compounds",
"compounds": [
// --- Primary Input Reactants ---
{ "name": "cobalt(II) chloride", "formula": "CoCl<sub>2</sub>", "molarMass": 129.84, "isSoluble": true, "solubilityLimit": 52.9, "color": "#e91e63", "ions": [{ "formula": "Co<sup>2+</sup>", "ratio": 1 }, { "formula": "Cl<sup>−</sup>", "ratio": 2 }] },
{ "name": "copper(II) sulfate", "formula": "CuSO<sub>4</sub>", "molarMass": 159.61, "isSoluble": true, "solubilityLimit": 22.0, "color": "#3498db", "ions": [{ "formula": "Cu<sup>2+</sup>", "ratio": 1 }, { "formula": "SO<sub>4</sub><sup>2−</sup>", "ratio": 1 }] },
{ "name": "lead(II) nitrate", "formula": "Pb(NO<sub>3</sub>)<sub>2</sub>", "molarMass": 331.21, "isSoluble": true, "solubilityLimit": 52.0, "color": "#ecf0f1", "ions": [{ "formula": "Pb<sup>2+</sup>", "ratio": 1 }, { "formula": "NO<sub>3</sub><sup>−</sup>", "ratio": 2 }] },
{ "name": "magnesium chloride", "formula": "MgCl<sub>2</sub>", "molarMass": 95.21, "isSoluble": true, "solubilityLimit": 54.3, "color": "#ecf0f1", "ions": [{ "formula": "Mg<sup>2+</sup>", "ratio": 1 }, { "formula": "Cl<sup>−</sup>", "ratio": 2 }] },
{ "name": "nickel(II) chloride", "formula": "NiCl<sub>2</sub>", "molarMass": 129.60, "isSoluble": true, "solubilityLimit": 64.2, "color": "#2ecc71", "ions": [{ "formula": "Ni<sup>2+</sup>", "ratio": 1 }, { "formula": "Cl<sup>−</sup>", "ratio": 2 }] },
{ "name": "potassium iodide", "formula": "KI", "molarMass": 166.00, "isSoluble": true, "solubilityLimit": 144.0, "color": "#ecf0f1", "ions": [{ "formula": "K<sup>+</sup>", "ratio": 1 }, { "formula": "I<sup>−</sup>", "ratio": 1 }] },
{ "name": "potassium permanganate", "formula": "KMnO<sub>4</sub>", "molarMass": 158.03, "isSoluble": true, "solubilityLimit": 7.6, "color": "#8e44ad", "ions": [{ "formula": "K<sup>+</sup>", "ratio": 1 }, { "formula": "MnO<sub>4</sub><sup>−</sup>", "ratio": 1 }] },
{ "name": "silver nitrate", "formula": "AgNO<sub>3</sub>", "molarMass": 169.87, "isSoluble": true, "solubilityLimit": 256.0, "color": "#ecf0f1", "ions": [{ "formula": "Ag<sup>+</sup>", "ratio": 1 }, { "formula": "NO<sub>3</sub><sup>−</sup>", "ratio": 1 }] },
{ "name": "sodium carbonate", "formula": "Na<sub>2</sub>CO<sub>3</sub>", "molarMass": 105.99, "isSoluble": true, "solubilityLimit": 30.7, "color": "#ecf0f1", "ions": [{ "formula": "Na<sup>+</sup>", "ratio": 2 }, { "formula": "CO<sub>3</sub><sup>2−</sup>", "ratio": 1 }] },
{ "name": "sodium chloride", "formula": "NaCl", "molarMass": 58.44, "isSoluble": true, "solubilityLimit": 36.0, "color": "#ecf0f1", "ions": [{ "formula": "Na<sup>+</sup>", "ratio": 1 }, { "formula": "Cl<sup>−</sup>", "ratio": 1 }] },
{ "name": "sodium phosphate", "formula": "Na<sub>3</sub>PO<sub>4</sub>", "molarMass": 163.94, "isSoluble": true, "solubilityLimit": 12.1, "color": "#ecf0f1", "ions": [{ "formula": "Na<sup>+</sup>", "ratio": 3 }, { "formula": "PO<sub>4</sub><sup>3−</sup>", "ratio": 1 }] },
// --- Soluble Reaction Products ---
{ "name": "barium nitrate", "formula": "Ba(NO<sub>3</sub>)<sub>2</sub>", "molarMass": 261.34, "isSoluble": true, "solubilityLimit": 10.2, "color": "#ecf0f1", "ions": [{ "formula": "Ba<sup>2+</sup>", "ratio": 1 }, { "formula": "NO<sub>3</sub><sup>−</sup>", "ratio": 2 }] },
{ "name": "cobalt(II) nitrate", "formula": "Co(NO<sub>3</sub>)<sub>2</sub>", "molarMass": 182.94, "isSoluble": true, "solubilityLimit": 101.9, "color": "#e91e63", "ions": [{ "formula": "Co<sup>2+</sup>", "ratio": 1 }, { "formula": "NO<sub>3</sub><sup>−</sup>", "ratio": 2 }] },
{ "name": "copper(II) nitrate", "formula": "Cu(NO<sub>3</sub>)<sub>2</sub>", "molarMass": 187.56, "isSoluble": true, "solubilityLimit": 137.0, "color": "#3498db", "ions": [{ "formula": "Cu<sup>2+</sup>", "ratio": 1 }, { "formula": "NO<sub>3</sub><sup>−</sup>", "ratio": 2 }] },
{ "name": "magnesium nitrate", "formula": "Mg(NO<sub>3</sub>)<sub>2</sub>", "molarMass": 148.32, "isSoluble": true, "solubilityLimit": 71.2, "color": "#ecf0f1", "ions": [{ "formula": "Mg<sup>2+</sup>", "ratio": 1 }, { "formula": "NO<sub>3</sub><sup>−</sup>", "ratio": 2 }] },
{ "name": "nickel(II) nitrate", "formula": "Ni(NO<sub>3</sub>)<sub>2</sub>", "molarMass": 182.70, "isSoluble": true, "solubilityLimit": 238.5, "color": "#2ecc71", "ions": [{ "formula": "Ni<sup>2+</sup>", "ratio": 1 }, { "formula": "NO<sub>3</sub><sup>−</sup>", "ratio": 2 }] },
{ "name": "potassium chloride", "formula": "KCl", "molarMass": 74.55, "isSoluble": true, "solubilityLimit": 36.0, "color": "#ecf0f1", "ions": [{ "formula": "K<sup>+</sup>", "ratio": 1 }, { "formula": "Cl<sup>−</sup>", "ratio": 1 }] },
{ "name": "potassium nitrate", "formula": "KNO<sub>3</sub>", "molarMass": 101.10, "isSoluble": true, "solubilityLimit": 38.3, "color": "#ecf0f1", "ions": [{ "formula": "K<sup>+</sup>", "ratio": 1 }, { "formula": "NO<sub>3</sub><sup>−</sup>", "ratio": 1 }] },
{ "name": "sodium iodide", "formula": "NaI", "molarMass": 149.89, "isSoluble": true, "solubilityLimit": 184.0, "color": "#ecf0f1", "ions": [{ "formula": "Na<sup>+</sup>", "ratio": 1 }, { "formula": "I<sup>−</sup>", "ratio": 1 }] },
{ "name": "sodium nitrate", "formula": "NaNO<sub>3</sub>", "molarMass": 85.00, "isSoluble": true, "solubilityLimit": 91.2, "color": "#ecf0f1", "ions": [{ "formula": "Na<sup>+</sup>", "ratio": 1 }, { "formula": "NO<sub>3</sub><sup>−</sup>", "ratio": 1 }] },
{ "name": "sodium sulfate", "formula": "Na<sub>2</sub>SO<sub>4</sub>", "molarMass": 142.04, "isSoluble": true, "solubilityLimit": 28.2, "color": "#ecf0f1", "ions": [{ "formula": "Na<sup>+</sup>", "ratio": 2 }, { "formula": "SO<sub>4</sub><sup>2−</sup>", "ratio": 1 }] }
]
},
{
"group": "Acids & Bases",
"compounds": [
{ "name": "barium hydroxide (strong)", "formula": "Ba(OH)<sub>2</sub>", "molarMass": 171.34, "isSoluble": true, "solubilityLimit": 3.89, "color": "#ecf0f1", "ions": [{ "formula": "Ba<sup>2+</sup>", "ratio": 1 }, { "formula": "OH<sup>−</sup>", "ratio": 2 }] },
{ "name": "hydrochloric acid (strong)", "formula": "HCl", "molarMass": 36.46, "isSoluble": true, "solubilityLimit": "Infinity", "color": "#ecf0f1", "ions": [{ "formula": "H<sup>+</sup>", "ratio": 1 }, { "formula": "Cl<sup>−</sup>", "ratio": 1 }] },
{ "name": "nitric acid (strong)", "formula": "HNO<sub>3</sub>", "molarMass": 63.01, "isSoluble": true, "solubilityLimit": "Infinity", "color": "#ecf0f1", "ions": [{ "formula": "H<sup>+</sup>", "ratio": 1 }, { "formula": "NO<sub>3</sub><sup>−</sup>", "ratio": 1 }] },
{ "name": "sodium hydroxide (strong)", "formula": "NaOH", "molarMass": 40.00, "isSoluble": true, "solubilityLimit": 129.0, "color": "#ecf0f1", "ions": [{ "formula": "Na<sup>+</sup>", "ratio": 1 }, { "formula": "OH<sup>−</sup>", "ratio": 1 }] }
]
},
{
"group": "Insoluble Compounds",
"compounds": [
{ "name": "barium carbonate", "formula": "BaCO<sub>3</sub>", "molarMass": 197.34, "isSoluble": false, "solubilityLimit": 0.0, "color": "#bdc3c7", "ions": [{ "formula": "Ba<sup>2+</sup>", "ratio": 1 }, { "formula": "CO<sub>3</sub><sup>2−</sup>", "ratio": 1 }] },
{ "name": "barium phosphate", "formula": "Ba<sub>3</sub>(PO<sub>4</sub>)<sub>2</sub>", "molarMass": 601.93, "isSoluble": false, "solubilityLimit": 0.0, "color": "#bdc3c7", "ions": [{ "formula": "Ba<sup>2+</sup>", "ratio": 3 }, { "formula": "PO<sub>4</sub><sup>3−</sup>", "ratio": 2 }] },
{ "name": "barium sulfate", "formula": "BaSO<sub>4</sub>", "molarMass": 233.39, "isSoluble": false, "solubilityLimit": 0.0, "color": "#bdc3c7", "ions": [{ "formula": "Ba<sup>2+</sup>", "ratio": 1 }, { "formula": "SO<sub>4</sub><sup>2−</sup>", "ratio": 1 }] },
{ "name": "cobalt(II) carbonate", "formula": "CoCO<sub>3</sub>", "molarMass": 118.94, "isSoluble": false, "solubilityLimit": 0.0, "color": "#e91e63", "ions": [{ "formula": "Co<sup>2+</sup>", "ratio": 1 }, { "formula": "CO<sub>3</sub><sup>2−</sup>", "ratio": 1 }] },
{ "name": "cobalt(II) hydroxide", "formula": "Co(OH)<sub>2</sub>", "molarMass": 92.95, "isSoluble": false, "solubilityLimit": 0.0, "color": "#e91e63", "ions": [{ "formula": "Co<sup>2+</sup>", "ratio": 1 }, { "formula": "OH<sup>−</sup>", "ratio": 2 }] },
{ "name": "copper(II) carbonate", "formula": "CuCO<sub>3</sub>", "molarMass": 123.55, "isSoluble": false, "solubilityLimit": 0.0, "color": "#3498db", "ions": [{ "formula": "Cu<sup>2+</sup>", "ratio": 1 }, { "formula": "CO<sub>3</sub><sup>2−</sup>", "ratio": 1 }] },
{ "name": "copper(II) hydroxide", "formula": "Cu(OH)<sub>2</sub>", "molarMass": 97.56, "isSoluble": false, "solubilityLimit": 0.0, "color": "#3498db", "ions": [{ "formula": "Cu<sup>2+</sup>", "ratio": 1 }, { "formula": "OH<sup>−</sup>", "ratio": 2 }] },
{ "name": "copper(II) phosphate", "formula": "Cu<sub>3</sub>(PO<sub>4</sub>)<sub>2</sub>", "molarMass": 380.58, "isSoluble": false, "solubilityLimit": 0.0, "color": "#3498db", "ions": [{ "formula": "Cu<sup>2+</sup>", "ratio": 3 }, { "formula": "PO<sub>4</sub><sup>3−</sup>", "ratio": 2 }] },
{ "name": "lead(II) carbonate", "formula": "PbCO<sub>3</sub>", "molarMass": 267.21, "isSoluble": false, "solubilityLimit": 0.0, "color": "#bdc3c7", "ions": [{ "formula": "Pb<sup>2+</sup>", "ratio": 1 }, { "formula": "CO<sub>3</sub><sup>2−</sup>", "ratio": 1 }] },
{ "name": "lead(II) chloride", "formula": "PbCl<sub>2</sub>", "molarMass": 278.10, "isSoluble": false, "solubilityLimit": 0.0, "color": "#bdc3c7", "ions": [{ "formula": "Pb<sup>2+</sup>", "ratio": 1 }, { "formula": "Cl<sup>−</sup>", "ratio": 2 }] },
{ "name": "lead(II) hydroxide", "formula": "Pb(OH)<sub>2</sub>", "molarMass": 241.21, "isSoluble": false, "solubilityLimit": 0.0, "color": "#bdc3c7", "ions": [{ "formula": "Pb<sup>2+</sup>", "ratio": 1 }, { "formula": "OH<sup>−</sup>", "ratio": 2 }] },
{ "name": "lead(II) iodide", "formula": "PbI<sub>2</sub>", "molarMass": 461.01, "isSoluble": false, "solubilityLimit": 0.0, "color": "#f1c40f", "ions": [{ "formula": "Pb<sup>2+</sup>", "ratio": 1 }, { "formula": "I<sup>−</sup>", "ratio": 2 }] },
{ "name": "lead(II) phosphate", "formula": "Pb<sub>3</sub>(PO<sub>4</sub>)<sub>2</sub>", "molarMass": 811.54, "isSoluble": false, "solubilityLimit": 0.0, "color": "#bdc3c7", "ions": [{ "formula": "Pb<sup>2+</sup>", "ratio": 3 }, { "formula": "PO<sub>4</sub><sup>3−</sup>", "ratio": 2 }] },
{ "name": "lead(II) sulfate", "formula": "PbSO<sub>4</sub>", "molarMass": 303.26, "isSoluble": false, "solubilityLimit": 0.0, "color": "#bdc3c7", "ions": [{ "formula": "Pb<sup>2+</sup>", "ratio": 1 }, { "formula": "SO<sub>4</sub><sup>2−</sup>", "ratio": 1 }] },
{ "name": "magnesium carbonate", "formula": "MgCO<sub>3</sub>", "molarMass": 84.31, "isSoluble": false, "solubilityLimit": 0.0, "color": "#bdc3c7", "ions": [{ "formula": "Mg<sup>2+</sup>", "ratio": 1 }, { "formula": "CO<sub>3</sub><sup>2−</sup>", "ratio": 1 }] },
{ "name": "magnesium hydroxide", "formula": "Mg(OH)<sub>2</sub>", "molarMass": 58.32, "isSoluble": false, "solubilityLimit": 0.0, "color": "#bdc3c7", "ions": [{ "formula": "Mg<sup>2+</sup>", "ratio": 1 }, { "formula": "OH<sup>−</sup>", "ratio": 2 }] },
{ "name": "nickel(II) carbonate", "formula": "NiCO<sub>3</sub>", "molarMass": 118.70, "isSoluble": false, "solubilityLimit": 0.0, "color": "#2ecc71", "ions": [{ "formula": "Ni<sup>2+</sup>", "ratio": 1 }, { "formula": "CO<sub>3</sub><sup>2−</sup>", "ratio": 1 }] },
{ "name": "nickel(II) hydroxide", "formula": "Ni(OH)<sub>2</sub>", "molarMass": 92.71, "isSoluble": false, "solubilityLimit": 0.0, "color": "#2ecc71", "ions": [{ "formula": "Ni<sup>2+</sup>", "ratio": 1 }, { "formula": "OH<sup>−</sup>", "ratio": 2 }] },
{ "name": "silver carbonate", "formula": "Ag<sub>2</sub>CO<sub>3</sub>", "molarMass": 275.75, "isSoluble": false, "solubilityLimit": 0.0, "color": "#bdc3c7", "ions": [{ "formula": "Ag<sup>+</sup>", "ratio": 2 }, { "formula": "CO<sub>3</sub><sup>2−</sup>", "ratio": 1 }] },
{ "name": "silver chloride", "formula": "AgCl", "molarMass": 143.32, "isSoluble": false, "solubilityLimit": 0.0, "color": "#bdc3c7", "ions": [{ "formula": "Ag<sup>+</sup>", "ratio": 1 }, { "formula": "Cl<sup>−</sup>", "ratio": 1 }] },
{ "name": "silver iodide", "formula": "AgI", "molarMass": 234.77, "isSoluble": false, "solubilityLimit": 0.0, "color": "#f1c40f", "ions": [{ "formula": "Ag<sup>+</sup>", "ratio": 1 }, { "formula": "I<sup>−</sup>", "ratio": 1 }] },
{ "name": "silver phosphate", "formula": "Ag<sub>3</sub>PO<sub>4</sub>", "molarMass": 418.58, "isSoluble": false, "solubilityLimit": 0.0, "color": "#f1c40f", "ions": [{ "formula": "Ag<sup>+</sup>", "ratio": 3 }, { "formula": "PO<sub>4</sub><sup>3−</sup>", "ratio": 1 }] },
{ "name": "silver sulfate", "formula": "Ag<sub>2</sub>SO<sub>4</sub>", "molarMass": 311.80, "isSoluble": false, "solubilityLimit": 0.0, "color": "#bdc3c7", "ions": [{ "formula": "Ag<sup>+</sup>", "ratio": 2 }, { "formula": "SO<sub>4</sub><sup>2−</sup>", "ratio": 1 }] }
]
},
{
"group": "Molecular Compounds",
"compounds": [
{ "name": "glucose", "formula": "C<sub>6</sub>H<sub>12</sub>O<sub>6</sub>", "molarMass": 180.16, "isSoluble": true, "solubilityLimit": 91.0, "color": "#ecf0f1", "ions": [] },
{ "name": "sucrose (sugar)", "formula": "C<sub>12</sub>H<sub>22</sub>O<sub>11</sub>", "molarMass": 342.30, "isSoluble": true, "solubilityLimit": 204.0, "color": "#ecf0f1", "ions": [] }
]
}
]
// =============================================================================
// 1.5 DYNAMIC MATHJAX LOADER
// =============================================================================
mathjax_dependency = {
if (window.our_mathjax_loaded) {
return Promise.resolve("MathJax already loaded.");
}
return new Promise(resolve => {
window.MathJax = {
loader: {
load: ['[tex]/html']
},
tex: {
packages: {'[+]': ['html']}
},
chtml: {
scale: 0.8
},
startup: {
ready: () => {
console.log("Dynamic MathJax is ready with custom \\molar command.");
window.MathJax.startup.defaultReady();
window.our_mathjax_loaded = true;
resolve("MathJax is now ready.");
}
}
};
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js';
script.async = true;
document.head.appendChild(script);
});
}
// =============================================================================
// 2. CSS STYLING for Precipitation Lab
// =============================================================================
precipitationLabStyles = htl.html`<style>
.precip-lab-container {
display: grid;
grid-template-columns: 1fr auto 1fr; /* Let the middle column shrink-to-fit */
grid-template-areas:
"title-area title-area title-area" /* NEW: Row for the main title */
"panel-a mix-panel panel-b" /* Row 1: Inputs */
"output-a . output-b" /* Row 2: Initial Outputs */
"output-mix output-mix output-mix"; /* Row 3: Final Mixture */
gap: 20px;
font-family: sans-serif;
max-width: 1100px;
margin: auto;
padding: 20px 20px 5px 20px;
border: 1px solid #ccc;
border-radius: 8px;
}
.widget-title {
grid-area: title-area;
text-align: center;
font-size: 1.5em;
font-weight: 600;
color: #2c3e50;
margin-bottom: 15px; /* Adds space below the title */
padding-bottom: 10px;
border-bottom: 1px solid #e0e0e0;
}
.widget-subtitle {
font-size: 0.7rem;
font-style: italic;
font-weight: 400; /* Resets bolding from parent */
color: #7f8c8d; /* Same color as the solute info */
margin-top: 4px; /* Adds a little space below the main title */
}
.saturation-alert {
margin-top: 10px;
padding: 8px 12px;
border: 1px solid #f39c12;
background-color: #fef9e7;
border-radius: 5px;
color: #b7791f;
font-size: 0.9rem;
text-align: center;
}
.reaction-warning-panel {
max-width: 550px; /* Match the width of the final output */
width: 100%;
margin-top: 15px;
padding: 15px;
border: 1px solid #c0392b; /* Red border */
background-color: #fdedec; /* Light red background */
border-radius: 5px;
text-align: center;
}
.reaction-warning-panel h4 {
margin-top: 0;
color: #c0392b;
}
.reaction-warning-panel p {
margin-bottom: 0;
color: #78281f;
}
.beaker-container {
width: 100%; /* Make container as wide as its parent */
display: flex;
flex-direction: column;
align-items: center; /* Center the h4 and svg inside */
}
.unit-molar {
font-variant-caps: small-caps;
font-size: 0.86em !important;
font-weight: 500;
}
b .unit-molar {
font-weight: 700; /* '700' is the numerical equivalent of 'bold' */
}
.mjx-molar mjx-c {
font-size: 0.80em !important;
font-weight: 500 !important;
}
.dissolution-equation {
text-align: center;
margin-top: 8px;
margin-bottom: 12px;
line-height: 1.4; /* Set a consistent line height */
min-height: 1.4em; /* Guarantee a minimum height even if empty */
}
.mjx-molar mjx-mtext {
vertical-align: bottom !important;
}
.input-panel {
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px;
background-color: #f9f9f9;
display: flex;
flex-direction: column;
gap: 5px;
}
#panel-a { grid-area: panel-a; }
#panel-b { grid-area: panel-b; }
#mix-panel {
grid-area: mix-panel;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 15px;
}
/* This single rule now correctly styles all output elements */
.solution-output {
justify-self: center;
width: 100%;
max-width: 100%;
display: flex;
flex-direction: column;
align-items: center;
&#solution-a-output { grid-area: output-a; }
&#solution-b-output { grid-area: output-b; }
&#final-mixture-output {
grid-area: output-mix;
max-width: 550px;
}
}
.solution-output h4 {
text-align: center;
font-size: 1.10em; /* You can adjust this value as needed */
font-weight: 700; /* 700 is the numeric equivalent of 'bold' */
}
.initial-output-wrapper {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.beaker-container {
width: 100%; /* Make container as wide as its parent */
display: flex;
flex-direction: column;
align-items: center; /* Center the h4 and svg inside */
}
#mix-button.mix-button-clean {
background-color: #2ecc71; /* A nice green */
border-color: #27ae60;
}
#mix-button.mix-button-clean:hover {
background-color: #27ae60;
}
.calculation-panel {
width: 100%;
margin-top: 15px;
padding: 15px 30px;
border: 1px solid #e0e0e0;
border-radius: 5px;
background-color: #fdfdfd;
text-align: center; /* This will center the math block */
}
.panel-title {
margin-top: 0;
margin-bottom: 0;
color: #333;
border-bottom: 2px solid #3498db;
padding-bottom: 5px;
}
.form-group { display: flex; flex-direction: column; gap: 5px; }
.input-unit {
white-space: nowrap;
}
.solute-info {
margin-top: 5px;
padding: 8px;
background-color: #ecf0f1;
border-radius: 4px;
font-size: 0.85rem;
color: #7f8c8d;
}
.input-element { width: 100%; box-sizing: border-box; }
.input-element { padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
.radio-group { display: flex; gap: 10px; align-items: center; }
.input-with-unit { display: flex; align-items: center; gap: 5px; }
#mix-button {
width: 100px; height: 100px; border-radius: 50%; font-size: 18px; font-weight: bold;
background-color: #e74c3c; border: 3px solid #c0392b;
}
#mix-button:hover { background-color: #c0392b; }
#mix-button:disabled { background-color: #bdc3c7; border-color: #95a5a6; cursor: not-allowed; }
.results-table {
min-width: 300px; /* Give the table a sensible minimum width */
border-collapse: collapse; /* Ensures padding is respected */
}
.results-table th, .results-table td {
white-space: nowrap; /* Prevent all cells from wrapping */
padding-right: 1.5em; /* Add consistent padding to ALL cells */
text-align: left; /* Align all cells to the left */
}
.results-table th {
font-weight: bold; /* Ensure headers are bold */
}
.placeholder { display: flex; align-items: center; justify-content: center; height: 100%; color: #aaa; text-align: center; }
.equation-block {
width: 100%;
margin-top: 15px;
padding: 10px 25px 5px 25px;
text-align: center;
font-family: sans-serif;
}
.molecular-equation {
margin-bottom: 1em; /* Controls space BETWEEN equations */
}
.molecular-equation p {
margin: 0.2em 0; /* Reduces space around the equation text itself */
}
.equation-products {
display: block; /* Forces the products onto a new line */
margin-left: 3em; /* Indents the products */
text-align: left; /* Aligns the indented line to the left */
}
.calculation-details {
width: 100%;
margin-top: 10px;
border: 1px solid #e0e0e0;
border-radius: 5px;
background-color: #fdfdfd;
position: relative;
z-index: 1;
}
body > div[style*="position: absolute"][style*="z-index"] {
z-index: 10000 !important;
}
.calculation-panel-content {
padding: 5px 30px; /* 15px top/bottom, 30px left/right */
text-align: center;
overflow-x: auto;
}
.calculation-details summary {
font-size: 0.9em;
padding: 10px 15px;
font-weight: bold;
cursor: pointer;
outline: none;
list-style-position: inside;
}
.calculation-details[open] > summary {
border-bottom: 1px solid #e0e0e0;
}
/* The .calculation-panel is now just for padding and centering the math */
.calculation-panel {
padding: 15px;
text-align: center;
}
#solution-a-output .calculation-panel,
#solution-b-output .calculation-panel {
padding: 15px 40px; /* Increase left/right padding here */
}
mjx-container {
position: relative;
z-index: 10000 !important;
}
@media (max-width: 900px) {
.precip-lab-container {
/* Switch to a single column layout */
grid-template-columns: 1fr;
/* Define a new stacking order for all elements */
grid-template-areas:
"title-area"
"panel-a"
"panel-b"
"mix-panel"
"output-a"
"output-b"
"output-mix";
}
/* To improve spacing on mobile, let's remove the fixed width
of the mix button and allow it to be more flexible */
#mix-panel {
padding: 20px 0;
}
#mix-button {
width: auto;
height: auto;
padding: 15px 30px;
border-radius: 8px;
}
}
</style>`
// =============================================================================
// 1. REACTIVE STATE for the Mixing Lab
// =============================================================================
mutable appState = ({
solutionA: {
// User Input State
soluteFormula: "NaCl",
userInputType: 'mass',
userInputAmount: 1.00,
userInputVolume: 100.0,
// Calculated State (populated after mixing)
isPrepared: false,
solute: null,
concentration: null,
moles: null,
volume: null,
ions: [],
latexInitialCalc: null
},
solutionB: {
// User Input State
soluteFormula: "Pb(NO<sub>3</sub>)<sub>2</sub>",
userInputType: 'mass',
userInputAmount: 1.00,
userInputVolume: 100.0,
// Calculated State
isPrepared: false,
solute: null,
concentration: null,
moles: null,
volume: null,
ions: [],
latexInitialCalc: null
},
mixture: {
isMixed: false,
isWarningState: false,
warningMessage: null,
precipitates: [],
limitingReactant: null,
neutralizationLR: null,
finalContents: [],
final_pH: null,
molecularEquation: null,
completeIonicEquation: null,
netIonicEquation: null,
latexPrecipitateCalc: null,
latexExcessReactantCalc: null,
latexNeutralizationCalc: null,
latexIonCalc: null
}
});
// =============================================================================
// 5: JAVASCRIPT LOGIC & RENDERER (FINAL, COMPLETE ARCHITECTURE)
// =============================================================================
{
// --- DEPENDENCIES ---
mathjax_dependency;
precipitationLabStyles;
appState;
const allCompounds = solutes.flatMap(g => g.compounds);
// --- INTELLIGENT DATA VALIDATION ---
const runDataValidation = () => {
console.group("Running Solution Mixer Data Validation...");
let issuesFound = 0;
// ... (This function can be collapsed for brevity in the final version if desired)
solutes.forEach(groupObject => {
groupObject.compounds.forEach(compound => {
if (compound.ions.length === 0 && (groupObject.group.includes("Salts") || groupObject.group.includes("Acids") || groupObject.group.includes("Bases"))) {
console.warn(`Data Issue: The ionic compound "${compound.name}" in group "${groupObject.group}" has an empty 'ions' array.`, compound);
issuesFound++;
}
if (compound.ions.length > 0) {
const hasCation = compound.ions.some(i => i.formula.includes('+'));
const hasAnion = compound.ions.some(i => i.formula.includes('−'));
if (!hasCation) { console.warn(`Data Issue: The ionic compound "${compound.name}" is missing a cation.`, compound); issuesFound++; }
if (!hasAnion) { console.warn(`Data Issue: The ionic compound "${compound.name}" is missing an anion.`, compound); issuesFound++; }
}
});
});
if (issuesFound === 0) {
console.log("Validation complete. No immediate issues found.");
} else {
console.error(`Validation complete. Found ${issuesFound} potential issue(s).`);
}
console.groupEnd();
};
runDataValidation();
// --- HTML GENERATION ---
const createSolutionPanel = (id, title) => {
const panel = htl.html`<div class="input-panel" id="panel-${id}">
<h3 class="panel-title">${title}</h3>
<div class="form-group">
<label for="solute-select-${id}">Select a Solute:</label>
<select id="solute-select-${id}" class="input-element">
${solutes.map(group => htl.html`<optgroup label="${group.group}">${
[...group.compounds].sort((a, b) => a.name.localeCompare(b.name)).map(c => htl.html`<option value="${c.formula}">${c.name}</option>`)
}</optgroup>`)}
</select>
<div id="solute-info-${id}" class="solute-info"></div>
</div>
<div class="form-group">
<label>Specify Solute Amount:</label>
<div class="radio-group">
<input type="radio" id="mass-radio-${id}" name="amount-type-${id}" value="mass" checked><label for="mass-radio-${id}">Mass</label>
<input type="radio" id="moles-radio-${id}" name="amount-type-${id}" value="moles"><label for="moles-radio-${id}">Moles</label>
<input type="radio" id="molarity-radio-${id}" name="amount-type-${id}" value="molarity"><label for="molarity-radio-${id}">Molarity</label>
</div>
<div class="input-with-unit">
<input type="number" id="amount-input-${id}" class="input-element" min="0" step="0.01"><span id="amount-unit-${id}" class="input-unit">(g)</span>
</div>
</div>
<div class="form-group">
<label for="volume-input-${id}">Set Final Solution Volume:</label>
<div class="input-with-unit">
<input type="number" id="volume-input-${id}" class="input-element" min="0" max="1000" step="0.1"><span class="input-unit">(mL)</span>
</div>
</div>
</div>`;
return panel;
};
const container = htl.html`
<div class="precip-lab-container">
<div class="widget-title">Solution Mixer<p class="widget-subtitle"><i>ρ</i>(H<sub>2</sub>O) = 1.00 g mL<sup>−1</sup> for all relevant calculations.</p></div>
${createSolutionPanel('a', 'Solution A')}
<div id="mix-panel"><button id="mix-button" class="action-button">Mix</button></div>
${createSolutionPanel('b', 'Solution B')}
<div id="solution-a-output" class="solution-output"></div>
<div id="solution-b-output" class="solution-output"></div>
<div id="final-mixture-output" class="solution-output" style="display: none;"></div>
</div>
`;
// --- ELEMENT SELECTORS ---
const mixButton = container.querySelector("#mix-button");
const solutionAOutput = container.querySelector("#solution-a-output");
const solutionBOutput = container.querySelector("#solution-b-output");
const finalMixtureOutput = container.querySelector("#final-mixture-output");
// --- HELPER FUNCTIONS ---
const countDecimalPlaces = (numStr) => { const s = String(numStr); if (s.includes('.')) { return s.split('.')[1].length; } return 0; };
const calculateSolutionFromInputs = (inputs) => {
if (!inputs) return null;
const { selectedSolute, amountType, amountValue, amountValueString, volumeValue } = inputs;
let requestedMass;
if (amountType === 'mass') { requestedMass = amountValue; }
else if (amountType === 'moles') { requestedMass = amountValue * selectedSolute.molarMass; }
else { const volumeInL = volumeValue / 1000; const molesNeeded = amountValue * volumeInL; requestedMass = molesNeeded * selectedSolute.molarMass; }
let dissolvedMass = 0, undissolvedMass = 0, isSaturated = false;
if (!selectedSolute.isSoluble) { dissolvedMass = 0; undissolvedMass = requestedMass; isSaturated = true; }
else if (selectedSolute.solubilityLimit === "Infinity") { dissolvedMass = requestedMass; }
else { const maxDissolvableMass = (selectedSolute.solubilityLimit / 100.0) * volumeValue; if (requestedMass > maxDissolvableMass) { isSaturated = true; dissolvedMass = maxDissolvableMass; undissolvedMass = requestedMass - dissolvedMass; } else { dissolvedMass = requestedMass; } }
const moles = dissolvedMass > 0 ? dissolvedMass / selectedSolute.molarMass : 0;
const concentration = volumeValue > 0 ? moles / (volumeValue / 1000) : 0;
const ions = selectedSolute.ions.map(ion => ({ formula: ion.formula, moles: moles * ion.ratio, concentration: concentration * ion.ratio }));
let latexInitialCalc = null;
if (amountType !== 'molarity' && dissolvedMass > 0) {
const sigFigs = 4;
const soluteFormula = cleanFormulaForLatex(selectedSolute.formula);
const molarMassFormatted = formatWithBar(String(selectedSolute.molarMass));
const volumeL = volumeValue / 1000;
const volumeDecimalPlaces = countDecimalPlaces(String(volumeValue));
const volumeLStr = volumeL.toFixed(volumeDecimalPlaces + 3);
const volumeLFormatted = formatWithBar(volumeLStr);
let blueprint = '', substitution = '';
if (amountType === 'mass') {
blueprint = `m(\\mathrm{${soluteFormula}}) ~ M(\\mathrm{${soluteFormula}})^{-1} ~ V^{-1}`;
substitution = `&= (${formatWithBar(amountValueString)})~\\mathrm{g} \\left( \\dfrac{1~\\mathrm{mol}}{${molarMassFormatted}~\\mathrm{g}} \\right) \\left( \\dfrac{1}{${volumeLFormatted}~\\mathrm{L}} \\right)`;
} else {
blueprint = `n(\\mathrm{${soluteFormula}}) ~ V^{-1}`;
substitution = `&= (${formatWithBar(amountValueString)})~\\mathrm{mol} \\left( \\dfrac{1}{${volumeLFormatted}~\\mathrm{L}} \\right)`;
}
latexInitialCalc = `$$ \\begin{align*} c(\\mathrm{${soluteFormula}}) &= ${blueprint} \\\\[1.5ex] ${substitution} \\\\[1.5ex] &= ${getUnroundedWithBar(concentration, sigFigs)}~\\class{mjx-molar}{\\mathrm{M}} \\\\[1.5ex] &= ${roundBankers(concentration, sigFigs).toPrecision(sigFigs)}~\\class{mjx-molar}{\\mathrm{M}} \\end{align*} $$`;
}
return { isPrepared: true, solute: selectedSolute, concentration, moles, volume: volumeValue, ions, isSaturated, undissolvedMass, latexInitialCalc };
};
const formatWithBar = (numStr) => { if (!numStr || typeof numStr !== 'string') return ''; if (numStr.includes('e')) return numStr; if (numStr.length === 1) return `\\bar{${numStr}}`; return `${numStr.slice(0, -1)}\\bar{${numStr.slice(-1)}}`; };
const getUnroundedWithBar = (num, sf) => { if (num === 0) return "0"; const precisionStr = num.toPrecision(sf); const fullStr = num.toPrecision(sf + 2); const guardDigits = fullStr.substring(precisionStr.length); let significantPart = precisionStr; let exponentPart = ''; if (precisionStr.includes('e')) { const parts = precisionStr.split('e'); significantPart = parts[0]; exponentPart = 'e' + parts[1]; } const barredPart = `${significantPart.slice(0, -1)}\\bar{${significantPart.slice(-1)}}`; return `${barredPart}${guardDigits}${exponentPart}`; };
const roundBankers = (num, sf) => { if (num === 0) return 0; if (!sf || sf < 1) return num; const magnitude = Math.pow(10, sf - Math.floor(Math.log10(Math.abs(num))) - 1); const scaled = num * magnitude; const floorScaled = Math.floor(scaled); const diff = scaled - floorScaled; let roundedScaled; if (diff === 0.5) { roundedScaled = (floorScaled % 2 === 0) ? floorScaled : floorScaled + 1; } else { roundedScaled = Math.round(scaled); } return roundedScaled / magnitude; };
const cleanFormulaForLatex = (formula) => { return formula.replace(/<sub>/g, '_{').replace(/<\/sub>/g, '}').replace(/<sup>/g, '{^{').replace(/<\/sup>/g, '}}').replace(/−/g, '-'); };
const hexToRgb = (hex) => { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) } : null; };
const rgbToHex = (r, g, b) => { return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).padStart(6, '0'); };
const updateInputsFromState = () => { for (const id of ['a', 'b']) { const solutionKey = id === 'a' ? 'solutionA' : 'solutionB'; const solutionState = appState[solutionKey]; const amountInput = container.querySelector(`#amount-input-${id}`); container.querySelector(`#solute-select-${id}`).value = solutionState.soluteFormula; container.querySelector(`#volume-input-${id}`).value = Number(solutionState.userInputVolume).toFixed(1); switch (solutionState.userInputType) { case 'moles': amountInput.value = Number(solutionState.userInputAmount).toFixed(3); container.querySelector(`#moles-radio-${id}`).checked = true; container.querySelector(`#amount-unit-${id}`).textContent = '(mol)'; break; case 'molarity': amountInput.value = Number(solutionState.userInputAmount).toFixed(4); container.querySelector(`#molarity-radio-${id}`).checked = true; container.querySelector(`#amount-unit-${id}`).innerHTML = '(mol L<sup>−1</sup>)'; break; case 'mass': default: amountInput.value = Number(solutionState.userInputAmount).toFixed(2); container.querySelector(`#mass-radio-${id}`).checked = true; container.querySelector(`#amount-unit-${id}`).textContent = '(g)'; break; } } };
// --- CORE LOGIC FUNCTION ---
const handleMix = () => {
const formulaA = appState.solutionA.soluteFormula;
const formulaB = appState.solutionB.soluteFormula;
const inputsA = { selectedSolute: allCompounds.find(c => c.formula === appState.solutionA.soluteFormula), amountType: appState.solutionA.userInputType, amountValue: appState.solutionA.userInputAmount, amountValueString: container.querySelector("#amount-input-a").value, volumeValue: appState.solutionA.userInputVolume, volumeDecimalPlaces: countDecimalPlaces(appState.solutionA.userInputVolume.toString()) };
const inputsB = { selectedSolute: allCompounds.find(c => c.formula === appState.solutionB.soluteFormula), amountType: appState.solutionB.userInputType, amountValue: appState.solutionB.userInputAmount, amountValueString: container.querySelector("#amount-input-b").value, volumeValue: appState.solutionB.userInputVolume, volumeDecimalPlaces: countDecimalPlaces(appState.solutionB.userInputVolume.toString()) };
if (!inputsA.selectedSolute || !inputsB.selectedSolute) {
alert("A valid solute must be selected for both solutions.");
return;
}
const calculatedA = calculateSolutionFromInputs(inputsA);
const calculatedB = calculateSolutionFromInputs(inputsB);
// --- Final, Comprehensive Safety Check ---
const isPermanganate = (f) => f === "KMnO<sub>4</sub>";
const isNitricAcid = (f) => f === "HNO<sub>3</sub>";
const isSugar = (f) => f === "C<sub>12</sub>H<sub>22</sub>O<sub>11</sub>" || f === "C<sub>6</sub>H<sub>12</sub>O<sub>6</sub>";
const isIodide = (f) => f === "KI" || f === "NaI";
const isAcid = (f) => f === "HNO<sub>3</sub>" || f === "HCl";
const isCarbonate = (f) => f === "Na<sub>2</sub>CO<sub>3</sub>";
const isPhosphate = (f) => f === "Na<sub>3</sub>PO<sub>4</sub>";
const isCopperII = (f) => f === "CuSO<sub>4</sub>" || f === "Cu(NO<sub>3</sub>)<sub>2</sub>"; // NEW CHECK
let incompatibilityType = null;
if ((isPermanganate(formulaA) && isSugar(formulaB)) || (isSugar(formulaA) && isPermanganate(formulaB))) {
incompatibilityType = 'redox-permanganate-sugar';
} else if ((isPermanganate(formulaA) && isIodide(formulaB)) || (isIodide(formulaA) && isPermanganate(formulaB))) {
incompatibilityType = 'redox-permanganate-iodide';
} else if ((isNitricAcid(formulaA) && isSugar(formulaB)) || (isSugar(formulaA) && isNitricAcid(formulaB))) {
incompatibilityType = 'redox-nitric-sugar';
} else if ((isAcid(formulaA) && isCarbonate(formulaB)) || (isCarbonate(formulaA) && isAcid(formulaB))) {
incompatibilityType = 'gas-forming-carbonate';
} else if ((isAcid(formulaA) && isPhosphate(formulaB)) || (isPhosphate(formulaA) && isAcid(formulaB))) {
incompatibilityType = 'weak-acid-base-phosphate';
} else if ((isCopperII(formulaA) && isIodide(formulaB)) || (isIodide(formulaA) && isCopperII(formulaB))) { // NEW CASE
incompatibilityType = 'redox-copper-iodide';
}
if (incompatibilityType) {
let warningTitle = "Reaction Blocked: Advanced Chemistry Detected";
let warningBody = "";
switch (incompatibilityType) {
case 'redox-permanganate-sugar':
warningTitle = "Reaction Blocked: Oxidation of a Carbohydrate";
warningBody = `Potassium permanganate is a powerful oxidizing agent. It will react with the sugar in a complex redox reaction, where the deep purple permanganate ion (MnO<sub>4</sub><sup>−</sup>) is reduced, typically to brown, solid manganese dioxide (MnO<sub>2</sub>(s)).`;
break;
case 'redox-permanganate-iodide':
warningTitle = "Reaction Blocked: Redox Reaction";
warningBody = `Potassium permanganate is a strong oxidizing agent that reacts with iodide ions (I<sup>−</sup>). The purple MnO<sub>4</sub><sup>−</sup> is reduced, while the colorless I<sup>−</sup> is oxidized to form brown aqueous iodine (I<sub>2</sub>). A representative reaction is:<p>2 MnO<sub>4</sub><sup>−</sup>(aq) + 10 I<sup>−</sup>(aq) → 2 Mn<sup>2+</sup>(aq) + 5 I<sub>2</sub>(aq)</p>`;
break;
case 'redox-nitric-sugar':
warningTitle = "Reaction Blocked: Oxidation of a Carbohydrate";
warningBody = `Nitric acid is a strong oxidizing agent that will vigorously react with and break down the sugar molecule. This complex redox reaction produces various products, including toxic brown nitrogen dioxide (NO<sub>2</sub>) gas.`;
break;
case 'gas-forming-carbonate':
warningTitle = "Reaction Blocked: Gas-Forming Reaction";
warningBody = `An acid reacts with a carbonate to form carbonic acid (H<sub>2</sub>CO<sub>3</sub>), which is unstable and immediately decomposes into water and carbon dioxide gas, which you would observe as fizzing or bubbling. The net ionic equation is:<p>2 H<sup>+</sup>(aq) + CO<sub>3</sub><sup>2−</sup>(aq) → H<sub>2</sub>O(l) + CO<sub>2</sub>(g)</p>`;
break;
case 'weak-acid-base-phosphate':
warningTitle = "Reaction Blocked: Weak Acid-Base Equilibrium";
warningBody = `The phosphate ion (PO<sub>4</sub><sup>3−</sup>) is a weak base. It reacts with a strong acid in a stepwise equilibrium, making it a buffer problem that involves complex calculations beyond the scope of this simulation. The first step of the reaction is:<p>H<sup>+</sup>(aq) + PO<sub>4</sub><sup>3−</sup>(aq) ⇌ HPO<sub>4</sub><sup>2−</sup>(aq)</p>`;
break;
case 'redox-copper-iodide':
warningTitle = "Reaction Blocked: Redox Reaction";
warningBody = `Copper(II) ions are strong enough to oxidize iodide ions. In this spontaneous redox reaction, Cu<sup>2+</sup> is reduced to Cu<sup>+</sup>, which precipitates as solid copper(I) iodide, while I<sup>−</sup> is oxidized to form brown aqueous iodine. The net ionic equation is:<p>2 Cu<sup>2+</sup>(aq) + 4 I<sup>−</sup>(aq) → 2 CuI(s) + I<sub>2</sub>(aq)</p>`;
break;
}
const fullMessage = `<h4>${warningTitle}</h4><p>${warningBody}</p>`;
appState.mixture = {
isMixed: true, isWarningState: true, warningMessage: fullMessage,
precipitates: [], limitingReactant: null, neutralizationLR: null, finalContents: [],
final_pH: null, molecularEquation: null, completeIonicEquation: null, netIonicEquation: null,
latexPrecipitateCalc: null, latexExcessReactantCalc: null, latexNeutralizationCalc: null, latexIonCalc: null
};
appState.solutionA = { ...appState.solutionA, ...calculatedA };
appState.solutionB = { ...appState.solutionB, ...calculatedB };
render();
return; // Stop execution
}
const finalVolume = (calculatedA.volume + calculatedB.volume) / 1000;
const sigFigs = 4;
const finalVolumeDecimalPlaces = Math.min(inputsA.volumeDecimalPlaces, inputsB.volumeDecimalPlaces);
const finalVolumeInMLStr = (calculatedA.volume + calculatedB.volume).toFixed(finalVolumeDecimalPlaces);
const finalVolumeSigFigs = finalVolumeInMLStr.replace('.', '').length;
const consolidatedSpecies = new Map();
const addSpeciesToMap = (solution) => {
if (solution.ions.length > 0) {
solution.ions.forEach(ion => {
const existingMoles = consolidatedSpecies.get(ion.formula) || 0;
consolidatedSpecies.set(ion.formula, existingMoles + ion.moles);
});
} else if (solution.solute.isSoluble) {
const existingMoles = consolidatedSpecies.get(solution.solute.formula) || 0;
consolidatedSpecies.set(solution.solute.formula, existingMoles + solution.moles);
}
};
addSpeciesToMap(calculatedA);
addSpeciesToMap(calculatedB);
let finalPrecipitates = [];
let limitingReactant = null,
latexPrecipitateCalc = null,
latexExcessReactantCalc = null,
latexNeutralizationCalc = null,
neutralizationLR = null,
latexIonCalc = null;
let molecularEquation = null,
completeIonicEquation = null,
netIonicEquation = null;
let final_pH = null;
const molesH = consolidatedSpecies.get('H<sup>+</sup>') || 0;
const molesOH = consolidatedSpecies.get('OH<sup>−</sup>') ||
0;
if (molesH > 1e-9 && molesOH > 1e-9) {
let lrFormula, erFormula, molesLR, molesER, molesER_final;
if (molesH < molesOH && Math.abs(molesH - molesOH) > 1e-9) {
lrFormula = 'H<sup>+</sup>';
erFormula = 'OH<sup>−</sup>';
molesLR = molesH;
molesER = molesOH;
molesER_final = molesER - molesLR;
const concOH_final = finalVolume > 0 ? molesER_final /
finalVolume : 0;
final_pH = concOH_final > 0 ? 14.00 + Math.log10(
concOH_final) : 7.00;
neutralizationLR = {
formula: lrFormula
};
} else if (molesOH < molesH && Math.abs(molesH - molesOH) >
1e-9) {
lrFormula = 'OH<sup>−</sup>';
erFormula = 'H<sup>+</sup>';
molesLR = molesOH;
molesER = molesH;
molesER_final = molesER - molesLR;
const concH_final = finalVolume > 0 ? molesER_final /
finalVolume : 0;
final_pH = concH_final > 0 ? -Math.log10(concH_final) :
7.00;
neutralizationLR = {
formula: lrFormula
};
} else {
molesLR = molesH;
molesER = molesOH;
molesER_final = 0;
final_pH = 7.00;
neutralizationLR = {
formula: 'Stoichiometric Mixture'
};
}
consolidatedSpecies.set('H<sup>+</sup>', 0);
consolidatedSpecies.set('OH<sup>−</sup>', 0);
if (molesER_final > 1e-9) {
consolidatedSpecies.set(erFormula, molesER_final);
}
if (molesER_final > 1e-9) {
latexNeutralizationCalc =
`$$ \\begin{align*} \\text{Net Ionic Eq: } & \\quad \\mathrm{H^+(aq) + OH^-(aq) \\rightarrow H_2O(l)} \\\\[1.5ex] n(\\mathrm{${cleanFormulaForLatex(erFormula)}})_{\\text{final}} &= n(\\mathrm{${cleanFormulaForLatex(erFormula)}})_{\\text{initial}} - n(\\mathrm{${cleanFormulaForLatex(lrFormula)}})_{\\text{initial}} \\\\[1.5ex] &= ${formatWithBar(molesER.toPrecision(sigFigs))}~\\mathrm{mol} - ${formatWithBar(molesLR.toPrecision(sigFigs))}~\\mathrm{mol} \\\\[1.5ex] &= ${getUnroundedWithBar(molesER_final, sigFigs)}~\\mathrm{mol} \\\\[1.5ex] &= ${roundBankers(molesER_final, sigFigs).toPrecision(sigFigs)}~\\mathrm{mol} \\end{align*} $$`;
}
let acidSolute, baseSolute;
if (calculatedA.solute.ions.some(i => i.formula ===
'H<sup>+</sup>')) {
acidSolute = calculatedA.solute;
baseSolute = calculatedB.solute;
} else {
acidSolute = calculatedB.solute;
baseSolute = calculatedA.solute;
}
const spectatorCation = baseSolute.ions.find(i => i.formula !==
'OH<sup>−</sup>');
const spectatorAnion = acidSolute.ions.find(i => i.formula !==
'H<sup>+</sup>');
if (spectatorAnion && spectatorCation) {
const saltFormula =
`${spectatorCation.formula.replace(/<sup>.*<\/sup>/, '')}${spectatorAnion.formula.replace(/<sup>.*<\/sup>/, '')}`;
molecularEquation =
`${acidSolute.formula}(aq) + ${baseSolute.formula}(aq) → H<sub>2</sub>O(l) + ${saltFormula}(aq)`;
} else {
molecularEquation = "Acid-base reaction occurred.";
}
}
if (calculatedA.concentration > 0 && calculatedB.concentration >
0 && calculatedA.ions.length > 0 && calculatedB.ions.length > 0
) {
const ionsA = calculatedA.ions;
const ionsB = calculatedB.ions;
const cationA = ionsA.find(ion => ion.formula.includes('+'));
const anionA = ionsA.find(ion => ion.formula.includes(
'−'));
const cationB = ionsB.find(ion => ion.formula.includes('+'));
const anionB = ionsB.find(ion => ion.formula.includes(
'−'));
if (cationA && anionA && cationB && anionB) {
const findProduct = (ion1, ion2) => allCompounds.find(c => c
.ions.length >= 2 && c.ions.some(i => i.formula ===
ion1.formula) && c.ions.some(i => i.formula ===
ion2.formula));
const p1 = findProduct(cationA, anionB);
const p2 = findProduct(cationB, anionA);
const precipitateCompound = [p1, p2].find(p => p && !p
.isSoluble);
if (precipitateCompound) {
const rIonInfo1 = precipitateCompound.ions.find(i => i
.formula === cationA.formula || i.formula ===
cationB.formula);
const rIonInfo2 = precipitateCompound.ions.find(i => i
.formula === anionA.formula || i.formula ===
anionB.formula);
const reactant1 = (calculatedA.solute.ions.some(i => i
.formula === rIonInfo1.formula) ||
calculatedA.solute.ions.some(i => i.formula ===
rIonInfo2.formula)) ? calculatedA.solute :
calculatedB.solute;
const reactant2 = reactant1 === calculatedA.solute ?
calculatedB.solute : calculatedA.solute;
const specIon1 = reactant1.ions.find(i => i.formula !==
rIonInfo1.formula && i.formula !== rIonInfo2
.formula);
const specIon2 = reactant2.ions.find(i => i.formula !==
rIonInfo1.formula && i.formula !== rIonInfo2
.formula);
const solubleProduct = allCompounds.find(c =>
specIon1 && specIon2 && c.ions.some(i => i
.formula === specIon1.formula) && c.ions
.some(i => i.formula === specIon2.formula));
if (solubleProduct) {
const getCharge = (ionFormula) => {
const match = ionFormula.match(
/<sup>([0-9])?([+-])/);
if (!match) return 0;
const num = match[1] ? parseInt(match[1]) :
1;
return match[2] === '+' ? num : -num;
};
const cation1Charge = Math.abs(getCharge(reactant1
.ions.find(i => i.formula.includes('+'))
.formula));
const cation2Charge = Math.abs(getCharge(reactant2
.ions.find(i => i.formula.includes('+'))
.formula));
const coeffR1 = cation2Charge;
const coeffR2 = cation1Charge;
const coeffPpt = 1;
const coeffSol = 1;
const compoundStr = (coeff, formula) =>
`${coeff > 1 ? coeff + ' ' : ''}${formula}`;
molecularEquation =
`${compoundStr(coeffR1, reactant1.formula)}(aq) + ${compoundStr(coeffR2, reactant2.formula)}(aq) → ${compoundStr(coeffPpt, precipitateCompound.formula)}(s) + ${compoundStr(coeffSol * coeffR1, solubleProduct.formula)}(aq)`;
const dissociate = (compound, coeff) => compound
.ions.map(ion =>
`${coeff * ion.ratio > 1 ? coeff * ion.ratio + ' ' : ''}${ion.formula}(aq)`
).join(' + ');
const cieReactants =
`${dissociate(reactant1, coeffR1)} + ${dissociate(reactant2, coeffR2)}`;
const cieProducts =
`${compoundStr(coeffPpt, precipitateCompound.formula)}(s) + ${dissociate(solubleProduct, coeffSol * coeffR1)}`;
completeIonicEquation =
`${cieReactants} <span class="equation-products">→ ${cieProducts}</span>`;
netIonicEquation =
`${dissociate(precipitateCompound, 1).replace(/\(aq\)/g, '(aq)').replace(/\(s\)/g, '(aq)')} → ${precipitateCompound.formula}(s)`;
}
const ion1_moles = consolidatedSpecies.get(rIonInfo1
.formula);
const ion2_moles = consolidatedSpecies.get(rIonInfo2
.formula);
const moles1_needed = ion1_moles / rIonInfo1.ratio;
const moles2_needed = ion2_moles / rIonInfo2.ratio;
let molesPrecipitate, molesExcess, excessReactantInfo;
if (moles1_needed < moles2_needed && Math.abs(
moles1_needed - moles2_needed) > 1e-9) {
limitingReactant = {
formula: rIonInfo1.formula,
moles: ion1_moles,
ratio: rIonInfo1.ratio
};
excessReactantInfo = {
formula: rIonInfo2.formula,
moles: ion2_moles,
ratio: rIonInfo2.ratio
};
molesPrecipitate = moles1_needed;
molesExcess = ion2_moles - (molesPrecipitate *
rIonInfo2.ratio);
consolidatedSpecies.set(rIonInfo1.formula, 0);
consolidatedSpecies.set(rIonInfo2.formula,
molesExcess);
} else if (moles2_needed < moles1_needed && Math.abs(
moles1_needed - moles2_needed) > 1e-9) {
limitingReactant = {
formula: rIonInfo2.formula,
moles: ion2_moles,
ratio: rIonInfo2.ratio
};
excessReactantInfo = {
formula: rIonInfo1.formula,
moles: ion1_moles,
ratio: rIonInfo1.ratio
};
molesPrecipitate = moles2_needed;
molesExcess = ion1_moles - (molesPrecipitate *
rIonInfo1.ratio);
consolidatedSpecies.set(rIonInfo2.formula, 0);
consolidatedSpecies.set(rIonInfo1.formula,
molesExcess);
} else {
limitingReactant = {
formula: 'Stoichiometric Mixture'
};
molesPrecipitate = moles1_needed;
consolidatedSpecies.set(rIonInfo1.formula, 0);
consolidatedSpecies.set(rIonInfo2.formula, 0);
}
const precipitateMass = molesPrecipitate *
precipitateCompound.molarMass;
finalPrecipitates.push({
formula: precipitateCompound.formula,
mass: precipitateMass,
moles: molesPrecipitate,
color: precipitateCompound.color,
ions: precipitateCompound.ions
});
if (limitingReactant.formula !==
'Stoichiometric Mixture') {
const pptFormula = cleanFormulaForLatex(
precipitateCompound.formula);
const lrFormula = cleanFormulaForLatex(
limitingReactant.formula);
const erFormula = cleanFormulaForLatex(
excessReactantInfo.formula);
const pptRatio = 1;
const lrRatio = limitingReactant.ratio;
const erRatio = excessReactantInfo.ratio;
latexPrecipitateCalc =
`$$ \\begin{align*} m(\\mathrm{${pptFormula}}) &= n(\\mathrm{${lrFormula}}) ~ r(\\mathrm{${pptFormula}}, \\mathrm{${lrFormula}}) ~ M(\\mathrm{${pptFormula}}) \\\\[1.5ex] &= (${formatWithBar(limitingReactant.moles.toPrecision(sigFigs))}~\\mathrm{mol}) \\left( \\dfrac{${pptRatio}~\\mathrm{mol}~\\mathrm{${pptFormula}}}{${lrRatio}~\\mathrm{mol}~\\mathrm{${lrFormula}}} \\right) \\left( \\dfrac{${formatWithBar(precipitateCompound.molarMass.toPrecision(4))}~\\mathrm{g}}{\\mathrm{mol}} \\right) \\\\[1.5ex] &= ${getUnroundedWithBar(precipitateMass, sigFigs)}~\\mathrm{g} \\\\[1.5ex] &= ${roundBankers(precipitateMass, sigFigs).toPrecision(sigFigs)}~\\mathrm{g} \\end{align*} $$`;
latexExcessReactantCalc =
`$$ \\begin{align*} n(\\mathrm{${erFormula}})_{\\text{final}} &= n(\\mathrm{${erFormula}})_{\\text{initial}} - n(\\mathrm{${erFormula}})_{\\text{reacted}} \\\\[1.5ex] &= n(\\mathrm{${erFormula}})_{\\text{initial}} - n(\\mathrm{${lrFormula}})_{\\text{initial}} ~ r(\\mathrm{${erFormula}}, \\mathrm{${lrFormula}}) \\\\[1.5ex] &= ${formatWithBar(excessReactantInfo.moles.toPrecision(sigFigs))}~\\mathrm{mol} - (${formatWithBar(limitingReactant.moles.toPrecision(sigFigs))}~\\mathrm{mol}) \\left( \\dfrac{${erRatio}~\\mathrm{mol}~\\mathrm{${erFormula}}}{${lrRatio}~\\mathrm{mol}~\\mathrm{${lrFormula}}} \\right) \\\\[1.5ex] &= ${getUnroundedWithBar(molesExcess, sigFigs)}~\\mathrm{mol} \\\\[1.5ex] &= ${roundBankers(molesExcess, sigFigs).toPrecision(sigFigs)}~\\mathrm{mol} \\end{align*} $$`;
}
}
}
}
if (calculatedA.undissolvedMass > 0) {
const moles = calculatedA.undissolvedMass / calculatedA.solute
.molarMass;
finalPrecipitates.push({
formula: calculatedA.solute.formula,
mass: calculatedA.undissolvedMass,
moles: moles,
color: calculatedA.solute.color,
ions: calculatedA.solute.ions
});
}
if (calculatedB.undissolvedMass > 0) {
const moles = calculatedB.undissolvedMass / calculatedB.solute
.molarMass;
finalPrecipitates.push({
formula: calculatedB.solute.formula,
mass: calculatedB.undissolvedMass,
moles: moles,
color: calculatedB.solute.color,
ions: calculatedB.solute.ions
});
}
const combinedPrecipitatesMap = new Map();
finalPrecipitates.forEach(p => {
if (combinedPrecipitatesMap.has(p.formula)) {
const existing = combinedPrecipitatesMap.get(p
.formula);
existing.mass += p.mass;
existing.moles += p.moles;
} else {
combinedPrecipitatesMap.set(p.formula, {
...p
});
}
});
const uniquePrecipitates = Array.from(combinedPrecipitatesMap
.values());
const finalVolumeLStr = finalVolume.toFixed(finalVolumeDecimalPlaces + 3);
const finalVolumeLFormatted = formatWithBar(finalVolumeLStr);
consolidatedSpecies.forEach((moles, formula) => {
if (moles > 1e-9 && formula.includes('<sup>')) {
if (latexIonCalc === null) {
latexIonCalc = ``;
}
const concentration = finalVolume > 0 ? moles / finalVolume : 0;
const cleanFormula = cleanFormulaForLatex(formula);
latexIonCalc += `$$ \\begin{align*} c(\\mathrm{${cleanFormula}}) &= n(\\mathrm{${cleanFormula}}) ~ V_{\\mathrm{total}}{^{-1}} \\\\[1.5ex]
&= \\dfrac{${formatWithBar(moles.toPrecision(sigFigs))}~\\mathrm{mol}}{${finalVolumeLFormatted}~\\mathrm{L}} \\\\[1.5ex]
&= ${getUnroundedWithBar(concentration, sigFigs)}~\\class{mjx-molar}{\\mathrm{M}} \\\\[1.5ex]
&= ${roundBankers(concentration, sigFigs).toPrecision(sigFigs)}~\\class{mjx-molar}{\\mathrm{M}}
\\end{align*} $$`;
}
});
const formattedContents = [];
consolidatedSpecies.forEach((moles, formula) => {
formattedContents.push({
formula: formula,
moles: moles,
concentration: finalVolume > 0 ? moles /
finalVolume : 0
});
});
const mixtureResult = {
isMixed: true,
precipitates: uniquePrecipitates,
limitingReactant: limitingReactant,
neutralizationLR: neutralizationLR,
finalContents: formattedContents,
final_pH: final_pH,
molecularEquation,
completeIonicEquation,
netIonicEquation,
latexPrecipitateCalc,
latexExcessReactantCalc,
latexNeutralizationCalc,
latexIonCalc
};
appState.solutionA = {
...appState.solutionA,
...calculatedA
};
appState.solutionB = {
...appState.solutionB,
...calculatedB
};
appState.mixture = mixtureResult;
render();
mixButton.style.backgroundColor = '#2ecc71';
mixButton.style.borderColor = '#27ae60';
};
// --- CORE RENDERING FUNCTION ---
const render = () => {
updateInputsFromState();
const renderSolution = (solutionState, outputElement, title) => {
if (!solutionState.isPrepared) { outputElement.innerHTML = ''; outputElement.style.display = 'none'; return; }
outputElement.style.display = 'block';
outputElement.innerHTML = '';
const wrapper = document.createElement('div');
wrapper.className = 'initial-output-wrapper';
const { solute, volume, concentration, ions, isSaturated, undissolvedMass, latexInitialCalc } = solutionState;
const sigFigs = 4;
const dissolvedMass = solutionState.moles * solute.molarMass;
const beakerWrapper = document.createElement('div');
beakerWrapper.className = 'beaker-container';
const svgWidth = 180, svgHeight = 160;
const titleElement = document.createElement('h4');
titleElement.textContent = title;
beakerWrapper.append(titleElement);
const svg = d3.create("svg").attr("viewBox", [0, 0, svgWidth, svgHeight]).attr("style", `width: 150px; height: auto;`);
const beakerBodyWidth = 90,
beakerBodyHeight = 135,
beakerX = (svgWidth - beakerBodyWidth) / 2,
beakerY = 10;
const beakerStrokeWidth = 2.5,
liquidPadding = 3.0,
innerWallOffset = beakerStrokeWidth / 2;
svg.append("path").attr("d",
`M ${beakerX + beakerBodyWidth + 8} ${beakerY} L ${beakerX} ${beakerY} L ${beakerX} ${beakerY + beakerBodyHeight} L ${beakerX + beakerBodyWidth} ${beakerY + beakerBodyHeight} L ${beakerX + beakerBodyWidth} ${beakerY}`
).attr("fill", "none").attr("stroke", "black").attr(
"stroke-width", beakerStrokeWidth);
svg.append("path").attr("d",
`M ${beakerX + beakerBodyWidth + 8} ${beakerY} L ${beakerX} ${beakerY} L ${beakerX} ${beakerY + beakerBodyHeight} L ${beakerX + beakerBodyWidth} ${beakerY + beakerBodyHeight} L ${beakerX + beakerBodyWidth} ${beakerY}`
).attr("fill", "none").attr("stroke", "black").attr(
"stroke-width", beakerStrokeWidth);
const liquidX = beakerX + innerWallOffset + liquidPadding;
const liquidWidth = beakerBodyWidth - 2 * (innerWallOffset +
liquidPadding);
const maxLiquidHeight = beakerBodyHeight - innerWallOffset -
liquidPadding;
const liquidHeight = (volume / 1000) * maxLiquidHeight;
const liquidY = beakerY + beakerBodyHeight -
innerWallOffset - liquidPadding - liquidHeight;
const opacityScale = d3.scaleLinear().domain([0, 5]).range([
0.1, 1.0
]).clamp(true);
const liquid = svg.append("rect").attr("x", liquidX).attr(
"y", liquidY).attr("width", liquidWidth).attr(
"height", liquidHeight);
if (!solute.isSoluble) {
liquid.attr("fill", "#aed6f1").attr("fill-opacity",
0.4);
} else {
liquid.attr("fill", solute.color).attr("fill-opacity",
opacityScale(concentration));
}
if (isSaturated || !solute.isSoluble) {
for (let i = 0; i < 35; i++) {
svg.append("circle").attr("cx", beakerX + 10 + Math
.random() * (beakerBodyWidth - 20)).attr(
"cy", beakerY + beakerBodyHeight - 5 - Math
.random() * 10).attr("r", 1.5 + Math
.random() * 1).attr("fill", solute.color)
.attr(
"stroke", "rgba(0,0,0,0.2)").attr(
"stroke-width", 1);
}
}
beakerWrapper.append(svg.node());
outputElement.append(beakerWrapper);
const mainTableNode = document.createElement('div');
mainTableNode.innerHTML = `<table class="results-table"><tbody><tr><th>Solute</th><td>${solute.name}</td></tr><tr><th>Concentration</th><td>${roundBankers(concentration, sigFigs).toPrecision(sigFigs)} <span class='unit-molar'>M</span></td></tr><tr><th>Dissolved Mass</th><td>${roundBankers(dissolvedMass, sigFigs).toPrecision(sigFigs)} g</td></tr><tr><th>Dissolved Moles</th><td>${roundBankers(solutionState.moles, sigFigs).toPrecision(sigFigs)} mol</td></tr><tr><th>Volume</th><td>${volume.toFixed(1)} mL</td></tr></tbody></table>`;
const equationDiv = document.createElement('div');
equationDiv.className = 'dissolution-equation';
let equationHTML = '';
if (solute.isSoluble) {
const initialState = (solute.formula === "HCl" || solute
.formula === "HNO<sub>3</sub>") ? '(aq)' : '(s)';
if (solute.ions.length > 0) {
const products = solute.ions.map(ion => {
const coefficient = ion.ratio > 1 ? ion
.ratio + ' ' : '';
return `${coefficient}${ion.formula}(aq)`;
}).join(' + ');
equationHTML =
`${solute.formula}${initialState} → ${products}`;
} else {
equationHTML =
`${solute.formula}(s) → ${solute.formula}(aq)`;
}
} else {
equationHTML = `<strong>Insoluble in water.</strong>`;
}
equationDiv.innerHTML = equationHTML;
outputElement.append(equationDiv);
outputElement.append(mainTableNode);
if (solutionState.moles > 1e-9) {
const speciesTableNode = document.createElement('div');
let speciesTableHTML =
`<table class="results-table" style="margin-top: 10px;"><tbody>`;
if (ions && ions.length > 0) {
speciesTableHTML +=
'<tr><th>Species</th><th>Moles</th><th>Concentration</th></tr>';
ions.forEach(ion => {
speciesTableHTML +=
`<tr><th style="padding-left: 20px;">${ion.formula}</th><td>${roundBankers(ion.moles, sigFigs).toPrecision(sigFigs)}</td><td>${roundBankers(ion.concentration, sigFigs).toPrecision(sigFigs)} <span class='unit-molar'>M</span></td></tr>`;
});
} else {
speciesTableHTML +=
'<tr><th>Species</th><th>Moles</th><th>Concentration</th></tr>';
speciesTableHTML +=
`<tr><th style="padding-left: 20px;">${solute.formula}</th><td>${roundBankers(solutionState.moles, sigFigs).toPrecision(sigFigs)}</td><td>${roundBankers(concentration, sigFigs).toPrecision(sigFigs)} <span class='unit-molar'>M</span></td></tr>`;
}
speciesTableHTML += `</tbody></table>`;
speciesTableNode.innerHTML = speciesTableHTML;
outputElement.append(speciesTableNode);
}
if (isSaturated && solute.isSoluble) {
const alertDiv = document.createElement('div');
alertDiv.className = 'saturation-alert';
alertDiv.innerHTML =
`<strong>Saturated:</strong> ${undissolvedMass.toFixed(2)} g of solid has not dissolved.`;
outputElement.append(alertDiv);
}
if (latexInitialCalc) {
const details = document.createElement('details');
details.className = 'calculation-details';
const summary = document.createElement('summary');
summary.textContent = 'Initial Concentration Calculation';
const contentDiv = document.createElement('div');
contentDiv.className = 'calculation-panel-content';
contentDiv.innerHTML = latexInitialCalc;
details.append(summary);
details.append(contentDiv);
outputElement.append(details);
if (window.MathJax) {
setTimeout(() => { window.MathJax.typesetPromise([contentDiv]); }, 0);
}
}
};
renderSolution(appState.solutionA, solutionAOutput, "Solution A");
renderSolution(appState.solutionB, solutionBOutput, "Solution B");
if (appState.mixture.isMixed) {
finalMixtureOutput.style.display = 'block';
finalMixtureOutput.innerHTML = '';
const mixture = appState.mixture;
if (mixture.isWarningState) {
const warningPanel = document.createElement('div');
warningPanel.className = 'reaction-warning-panel';
warningPanel.innerHTML = mixture.warningMessage;
finalMixtureOutput.append(warningPanel);
return; // Stop rendering the normal output
}
const sigFigs = 4;
const mainContentWrapper = document.createElement('div');
mainContentWrapper.style.display = 'flex';
mainContentWrapper.style.flexDirection = 'column';
mainContentWrapper.style.alignItems = 'center';
const beakerWrapper = document.createElement('div');
beakerWrapper.className = 'beaker-container';
const svgWidth = 220,
svgHeight = 200;
const titleElement = document.createElement('h4');
titleElement.textContent = "Final Mixture";
beakerWrapper.append(titleElement);
const svg = d3.create("svg").attr("viewBox", [0, 0, svgWidth,
svgHeight
]).attr("style", `width: 220px; height: auto;`);
const beakerBodyWidth = 110,
beakerBodyHeight = 170,
beakerX = (svgWidth - beakerBodyWidth) / 2,
beakerY = 15;
const beakerStrokeWidth = 2.5,
liquidPadding = 3.0,
innerWallOffset = beakerStrokeWidth / 2;
svg.append("path").attr("d",
`M ${beakerX + beakerBodyWidth + 8} ${beakerY} L ${beakerX} ${beakerY} L ${beakerX} ${beakerY + beakerBodyHeight} L ${beakerX + beakerBodyWidth} ${beakerY + beakerBodyHeight} L ${beakerX + beakerBodyWidth} ${beakerY}`
).attr("fill", "none").attr("stroke", "black").attr(
"stroke-width", beakerStrokeWidth);
const liquidX = beakerX + innerWallOffset + liquidPadding;
const liquidWidth = beakerBodyWidth - 2 * (innerWallOffset +
liquidPadding);
const maxLiquidHeight = beakerBodyHeight - innerWallOffset -
liquidPadding;
const liquidHeight = ((appState.solutionA.volume + appState
.solutionB.volume) / 2000) * maxLiquidHeight;
const liquidY = beakerY + beakerBodyHeight - innerWallOffset -
liquidPadding - liquidHeight;
let finalColorHex = '#aed6f1';
let totalColoredConcentration = 0;
const coloredSpeciesInMixture = mixture.finalContents.map(
item => {
const compoundData = allCompounds.find(c => c
.formula === item.formula || c.ions.some(
i => i.formula === item.formula));
if (compoundData && compoundData.color !==
'#ecf0f1') {
return {
concentration: item.concentration,
rgb: hexToRgb(compoundData.color)
};
}
return null;
}).filter(Boolean);
if (coloredSpeciesInMixture.length > 0) {
totalColoredConcentration = coloredSpeciesInMixture.reduce((
sum, s) => sum + s.concentration, 0);
if (coloredSpeciesInMixture.length === 1) {
const singleRgb = coloredSpeciesInMixture[0].rgb;
finalColorHex = rgbToHex(singleRgb.r, singleRgb.g,
singleRgb.b);
} else {
let totalR = 0,
totalG = 0,
totalB = 0;
coloredSpeciesInMixture.forEach(s => {
totalR += s.rgb.r * s.concentration;
totalG += s.rgb.g * s.concentration;
totalB += s.rgb.b * s.concentration;
});
const avgR = Math.round(totalR /
totalColoredConcentration);
const avgG = Math.round(totalG /
totalColoredConcentration);
const avgB = Math.round(totalB /
totalColoredConcentration);
finalColorHex = rgbToHex(avgR, avgG, avgB);
}
}
const liquid = svg.append("rect")
.attr("x", liquidX).attr("y", liquidY)
.attr("width", liquidWidth).attr("height", liquidHeight);
const opacityScale = d3.scaleLinear().domain([0, 2.0]).range([
0.1, 1.0
]).clamp(true);
liquid.attr("fill", finalColorHex)
.attr("fill-opacity", totalColoredConcentration > 0 ?
opacityScale(totalColoredConcentration) : 0.3);
if (mixture.precipitates && mixture.precipitates.length > 0) {
const precipitateColor = mixture.precipitates[0].color;
for (let i = 0; i < 50; i++) {
svg.append("circle").attr("cx", beakerX + 10 + Math
.random() * (beakerBodyWidth - 20)).attr("cy",
beakerY + beakerBodyHeight - 8 - Math.random() *
20).attr("r", 1.5 + Math.random() * 2).attr(
"fill", precipitateColor).attr("stroke",
"rgba(0,0,0,0.2)").attr("stroke-width", 1);
}
}
beakerWrapper.append(svg.node());
mainContentWrapper.append(beakerWrapper);
finalMixtureOutput.append(mainContentWrapper);
const bottomWrapper = document.createElement('div');
bottomWrapper.style.width = '100%';
bottomWrapper.style.display = 'flex';
bottomWrapper.style.flexDirection = 'column';
bottomWrapper.style.alignItems = 'center';
const textResultsNode = document.createElement('div');
textResultsNode.style.display = 'flex';
textResultsNode.style.flexDirection = 'column';
textResultsNode.style.alignItems = 'center';
const htmlParts = [];
const reactionOccurred = mixture.limitingReactant || mixture
.neutralizationLR;
if (reactionOccurred && mixture.molecularEquation) {
let equationHTML = `<div class="equation-block">`;
equationHTML +=
`<div class="molecular-equation"><b>Molecular Equation</b><p>${mixture.molecularEquation}</p></div>`;
equationHTML +=
`<div class="molecular-equation"><b>Complete Ionic Equation</b><p>${mixture.completeIonicEquation}</p></div>`;
equationHTML +=
`<div class="molecular-equation"><b>Net Ionic Equation</b><p>${mixture.netIonicEquation}</p></div>`;
equationHTML += `</div>`;
htmlParts.push(equationHTML);
}
if (mixture.precipitates.length === 0 && !reactionOccurred) {
const hasIons = mixture.finalContents.some(item => item
.formula.includes('<sup>'));
if (hasIons) {
htmlParts.push(
`<div class="saturation-alert" style="display: block; background-color: #e8f6f3; border-color: #16a085;"><strong>No Reaction Occurred: All ions are spectators.</strong></div>`
);
} else {
htmlParts.push(
`<div class="saturation-alert" style="display: block; background-color: #e8f6f3; border-color: #16a085;"><strong>No Reaction Occurred: The molecular compounds do not react.</strong></div>`
);
}
} else {
htmlParts.push(`<table class="results-table"><tbody>`);
if (mixture.precipitates.length > 0) {
mixture.precipitates.forEach(p => {
htmlParts.push(
`<tr><th>Mass of ${p.formula}(s)</th><td>${roundBankers(p.mass, sigFigs).toPrecision(sigFigs)} g</td></tr>`
);
htmlParts.push(
`<tr><th style="padding-left: 20px;">↳ Moles</th><td>${roundBankers(p.moles, sigFigs).toPrecision(sigFigs)} mol</td></tr>`
);
});
}
if (mixture.limitingReactant) {
htmlParts.push(
`<tr><th>Limiting Reactant</th><td>${mixture.limitingReactant.formula}</td></tr>`
);
}
if (mixture.neutralizationLR) {
htmlParts.push(
`<tr><th>Limiting Reactant</th><td>${mixture.neutralizationLR.formula}</td></tr>`
);
}
if (mixture.final_pH) {
htmlParts.push(
`<tr><th>Final pH</th><td>${mixture.final_pH.toFixed(2)}</td></tr>`
);
}
htmlParts.push(`</tbody></table>`);
}
htmlParts.push(
`<table class="results-table" style="margin-top: 15px;"><tbody>`
);
htmlParts.push(
'<tr><th>Species</th><th>Moles</th><th>Concentration</th></tr>'
);
mixture.finalContents.sort((a, b) => a.formula.includes('⁻') - b
.formula.includes('⁻')).forEach(item => {
const formulaDisplay = item.formula.includes(
'<sup>') ? item.formula : item.formula;
htmlParts.push(
`<tr><th style="padding-left: 20px;">${formulaDisplay}</th><td>${roundBankers(item.moles, sigFigs).toPrecision(sigFigs)}</td><td>${roundBankers(item.concentration, sigFigs).toPrecision(sigFigs)} <span class='unit-molar'>M</span></td></tr>`
);
});
htmlParts.push(`</tbody></table>`);
textResultsNode.innerHTML = htmlParts.join('');
bottomWrapper.append(textResultsNode);
const mathPanels = [];
const createCalculationPanel = (title, latexContent) => {
if (!latexContent) return null;
const details = document.createElement('details');
details.className =
'calculation-details'; // Main style for the outer box
const summary = document.createElement('summary');
summary.textContent = title;
// The math content now goes into a div that ONLY handles padding/centering
const contentDiv = document.createElement('div');
contentDiv.className =
'calculation-panel-content'; // A new class for this purpose
contentDiv.innerHTML = latexContent;
details.append(summary);
details.append(contentDiv); // Append the content div
bottomWrapper.append(details);
return contentDiv; // Return the content div for MathJax
};
const neutPanel = createCalculationPanel(
"Neutralization Calculation", mixture
.latexNeutralizationCalc);
if (neutPanel) mathPanels.push(neutPanel);
const precipPanel = createCalculationPanel(
"Precipitate Calculation", mixture.latexPrecipitateCalc);
if (precipPanel) mathPanels.push(precipPanel);
const excessPanel = createCalculationPanel(
"Excess Reactant Calculation", mixture
.latexExcessReactantCalc);
if (excessPanel) mathPanels.push(excessPanel);
const ionPanel = createCalculationPanel(
"Final Ion Concentration Calculations", mixture
.latexIonCalc);
if (ionPanel) mathPanels.push(ionPanel);
finalMixtureOutput.append(bottomWrapper);
if (mathPanels.length > 0 && window.MathJax) {
setTimeout(() => {
window.MathJax.typesetPromise(mathPanels);
}, 0);
}
} else {
finalMixtureOutput.style.display = 'none';
}
};
// --- EVENT LISTENER SETUP ---
const setupInputListeners = (id) => {
const panel = container.querySelector(`#panel-${id}`);
const solutionKey = id === 'a' ? 'solutionA' : 'solutionB';
panel.querySelector(`#solute-select-${id}`).addEventListener('change', (event) => {
appState[solutionKey].soluteFormula = event.target.value;
mixButton.style.backgroundColor = '';
mixButton.style.borderColor = '';
const solute = allCompounds.find(c => c.formula === event.target.value);
const infoBox = panel.querySelector(`#solute-info-${id}`);
if (!solute) return;
let solubilityText;
if (solute.solubilityLimit === "Infinity") { solubilityText = "Miscible in water"; }
else if (!solute.isSoluble) { solubilityText = "Insoluble in water"; }
else { solubilityText = `${solute.solubilityLimit} g / 100 g H₂O`; }
infoBox.innerHTML = `Molar Mass: ${solute.molarMass} g mol<sup>−1</sup> <br> Solubility (25 °C): ${solubilityText}`;
});
panel.querySelector(`#amount-input-${id}`).addEventListener('input', (event) => {
appState[solutionKey].userInputAmount = parseFloat(event.target.value) || 0;
mixButton.style.backgroundColor = '';
mixButton.style.borderColor = '';
});
panel.querySelector(`#volume-input-${id}`).addEventListener('input', (event) => {
appState[solutionKey].userInputVolume = parseFloat(event.target.value) || 0;
mixButton.style.backgroundColor = '';
mixButton.style.borderColor = '';
});
panel.querySelectorAll(`input[name="amount-type-${id}"]`).forEach(radio => {
radio.addEventListener('change', (event) => {
const amountInput = panel.querySelector(`#amount-input-${id}`);
const unitSpan = panel.querySelector(`#amount-unit-${id}`);
appState[solutionKey].userInputType = event.target.value;
if (event.target.value === 'mass') {
unitSpan.textContent = '(g)';
amountInput.value = '10.00';
appState[solutionKey].userInputAmount = 10.00;
} else if (event.target.value === 'moles') {
unitSpan.textContent = '(mol)';
amountInput.value = '1.000';
appState[solutionKey].userInputAmount = 1.000;
} else {
unitSpan.innerHTML = '(mol L<sup>−1</sup>)';
amountInput.value = '0.1000';
appState[solutionKey].userInputAmount = 0.1000;
}
mixButton.style.backgroundColor = '';
mixButton.style.borderColor = '';
});
});
};
// --- INITIALIZATION ---
setupInputListeners('a');
setupInputListeners('b');
mixButton.addEventListener("click", handleMix);
container.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
mixButton.click();
}
});
setTimeout(() => {
render();
container.querySelector("#solute-select-a").dispatchEvent(new Event('change'));
container.querySelector("#solute-select-b").dispatchEvent(new Event('change'));
}, 0);
return container;
}