// =============================================================================
// 1. DATA
// The JSON data is now hardcoded directly into the .qmd file.
// This bypasses all FileAttachment and fetch errors.
// =============================================================================
solutes = [
{
"group": "Common Salts (Ionic)",
"compounds": [
{ "name": "sodium chloride", "formula": "NaCl", "molarMass": 58.44, "isSoluble": true, "solubilityLimit": 36.0, "color": "#e74c3c", "ions": [{ "formula": "Na<sup>+</sup>", "ratio": 1 }, { "formula": "Cl<sup>−</sup>", "ratio": 1 }] },
{ "name": "magnesium chloride", "formula": "MgCl<sub>2</sub>", "molarMass": 95.21, "isSoluble": true, "solubilityLimit": 54.3, "color": "#9b59b6", "ions": [{ "formula": "Mg<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": "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": "#95a5a6", "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": "#95a5a6", "ions": [{ "formula": "Na<sup>+</sup>", "ratio": 2 }, { "formula": "CO<sub>3</sub><sup>2−</sup>", "ratio": 1 }] }
]
},
{
"group": "Insoluble Compounds",
"compounds": [
{ "name": "silver chloride", "formula": "AgCl", "molarMass": 143.32, "isSoluble": false, "solubilityLimit": 0.0, "color": "#bdc3c7", "ions": [] },
{ "name": "lead(II) iodide", "formula": "PbI<sub>2</sub>", "molarMass": 461.01, "isSoluble": false, "solubilityLimit": 0.0, "color": "#f1c40f", "ions": [] },
{ "name": "barium sulfate", "formula": "BaSO<sub>4</sub>", "molarMass": 233.39, "isSoluble": false, "solubilityLimit": 0.0, "color": "#ecf0f1", "ions": [] }
]
},
{
"group": "Molecular Compounds",
"compounds": [
{ "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": "#95a5a6", "ions": [] },
{ "name": "glucose", "formula": "C<sub>6</sub>H<sub>12</sub>O<sub>6</sub>", "molarMass": 180.16, "isSoluble": true, "solubilityLimit": 91.0, "color": "#95a5a6", "ions": [] }
]
},
{
"group": "Acids & Bases",
"compounds": [
{ "name": "hydrochloric acid (strong)", "formula": "HCl", "molarMass": 36.46, "isSoluble": true, "solubilityLimit": "Infinity", "color": "#95a5a6", "ions": [{ "formula": "H<sup>+</sup>", "ratio": 1 }, { "formula": "Cl<sup>−</sup>", "ratio": 1 }] },
{ "name": "sodium hydroxide (strong)", "formula": "NaOH", "molarMass": 40.00, "isSoluble": true, "solubilityLimit": "Infinity", "color": "#95a5a6", "ions": [{ "formula": "Na<sup>+</sup>", "ratio": 1 }, { "formula": "OH<sup>−</sup>", "ratio": 1 }] },
{ "name": "acetic acid (weak)", "formula": "CH<sub>3</sub>COOH", "molarMass": 60.05, "isSoluble": true, "solubilityLimit": "Infinity", "color": "#95a5a6", "ions": [] }
]
}
]
Solution Lab
// =============================================================================
// 1.5 DYNAMIC MATHJAX LOADER
// This cell correctly loads the MathJax script and waits for it to be ready.
// =============================================================================
mathjax_dependency = {
// Check if we have already successfully loaded our script.
if (window.our_mathjax_loaded) {
return Promise.resolve("MathJax already loaded.");
}
// Return a Promise. OJS will wait for this promise to resolve.
return new Promise(resolve => {
// 1. Configure MathJax *before* the script loads. The script will look for
// this global object and use its settings during initialization.
window.MathJax = {
// 2. Target the Common HTML output processor.
chtml: {
// 3. Set the scaling factor. 1.0 is 100% (default). 0.9 is 90%.
scale: 0.9
},
// 4. Use the official 'ready' callback to know when MathJax is initialized.
startup: {
ready: () => {
console.log("Dynamic MathJax is ready with custom font scale.");
// Let the default MathJax startup proceed.
window.MathJax.startup.defaultReady();
// Set our flag.
window.our_mathjax_loaded = true;
// Resolve the promise to unblock the main app cell.
resolve("MathJax is now ready.");
}
}
};
// 5. Create and append the script tag. It will now use the configuration above.
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 (Corrected for Width and Spacing)
// =============================================================================
solutionLabStyles = htl.html`<style>
.solution-lab-container {
max-width: 850px; /* Adjust this value to your preference */
margin: auto;
display: flex;
gap: 30px; /* Increased gap for a more spacious layout */
font-family: sans-serif;
border: 1px solid #ccc; border-radius: 8px;
padding: 20px; background-color: #f9f9f9;
}
.input-panel {
/* This panel will now grow to fill available space */
flex: 1 1 auto;
min-width: 300px; /* Ensures it's usable on smaller screens before wrapping */
display: flex;
flex-direction: column;
gap: 15px;
}
.output-panel {
/* This panel now has a fixed, ideal width and will not grow */
flex: 0 0 450px;
border: 1px solid #ddd;
border-radius: 5px;
background-color: #fff;
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
}
/* Reset default margins on text elements inside the input panel */
.input-panel > h2,
.input-panel > p {
margin: 0;
}
.action-button {
margin-top: 10px; /* Add a deliberate space above the main button */
}
.placeholder { display: flex; align-items: center; justify-content: center; height: 100%; color: #aaa; text-align: center; }
.form-group { display: flex; max-width: 250px; flex-direction: column; gap: 5px; }
.panel-title { margin-top: 0; color: #333; border-bottom: 2px solid #3498db; padding-bottom: 5px; }
.input-element { width: 100%; max-width: 250px; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
.radio-group { display: flex; gap: 10px; align-items: center; }
.input-with-unit { display: flex; align-items: center; gap: 5px; }
.input-with-unit span { color: #555; }
.action-button { padding: 12px; border: none; max-width: 250px; border-radius: 5px; background-color: #3498db; color: white; font-size: 16px; cursor: pointer; transition: background-color 0.2s; }
.action-button:hover { background-color: #2980b9; }
.results-table { width: 100%; border-collapse: collapse; margin-top: 15px; }
.results-table th, .results-table td { padding: 8px; text-align: left; border-bottom: 1px solid #ddd; }
.results-table th { font-weight: bold; color: #333; }
.results-table td { color: #555; }
.saturation-alert { margin-top: 15px; padding: 10px 15px; border: 1px solid #f39c12; background-color: #fef9e7; border-radius: 5px; color: #b7791f; font-size: 0.9rem; }
.saturation-alert strong { color: #9c681a; }
.solute-info { margin-top: 5px; max-width: 250px; padding: 8px; background-color: #ecf0f1; border-radius: 4px; font-size: 0.85rem; color: #7f8c8d; }
.calculation-panel {
width: 100%;
margin-top: 20px;
padding: 10px;
border: 1px solid #e0e0e0;
border-radius: 5px;
background-color: #fdfdfd;
}
.calculation-panel h4 {
margin: 0 0 10px 0;
font-size: 1rem;
color: #333;
border-bottom: 1px solid #eee;
padding-bottom: 5px;
}
.mjx-molar mjx-c {
font-size: 0.85em !important; /* Make the 'M' character smaller */
font-weight: 500 !important; /* Make it slightly bolder to compensate for the size reduction */
transform: translateY(-0.000em); /* Nudge it in the Y direction */
}
.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' */
}
@media (max-width: 768px) {
.solution-lab-container {
flex-direction: column; /* Stack panels vertically */
padding: 15px; /* Reduce padding on mobile */
}
.input-panel, .output-panel {
/* Override desktop flex settings and make panels full-width */
flex: 1 1 100%;
width: 100%;
min-width: 0;
}
}
</style>`
// =============================================================================
// 3. REACTIVE STATE (Updated)
// =============================================================================
mutable solutionState = ({
// User Input State
userInputType: null,
userInputAmount: null,
userInputVolume: null,
// Calculated Solution State
solute: null,
requestedMass: null,
dissolvedMass: null,
undissolvedMass: 0,
moles: null,
volume: null,
concentration: null,
saturationConcentration: null,
isInsoluble: false,
isSaturated: false,
isPrepared: false,
// NEW: Property to hold the LaTeX string for the calculation
calculationLatex: null,
limitingSigFigs: 3
});
// =============================================================================
// 4. INITIALIZER (Corrected for Precision)
// =============================================================================
{
if (!solutionState.isPrepared) {
const allCompounds = solutes.flatMap(g => g.compounds);
const defaultSolute = allCompounds[0];
const defaultAmount = 10.00;
const defaultVolume = 250.0;
const requestedMass = defaultAmount;
const dissolvedMass = defaultAmount;
const moles = requestedMass / defaultSolute.molarMass;
const concentration = moles / (defaultVolume / 1000);
const limitingSigFigs = 4; // Based on 10.00 (4 sf) and 250.0 (4 sf)
mutable solutionState = ({
userInputType: 'mass',
userInputAmount: defaultAmount,
userInputVolume: defaultVolume,
solute: defaultSolute,
requestedMass, dissolvedMass, undissolvedMass: 0, moles,
volume: defaultVolume, concentration,
saturationConcentration: null,
isInsoluble: false, isSaturated: false, isPrepared: true,
calculationLatex: null,
limitingSigFigs: limitingSigFigs
});
}
return htl.html``;
}
// =============================================================================
// 5. MAIN APPLICATION RENDERER (Complete, Unabbreviated, and Verified)
// =============================================================================
{
mathjax_dependency;
solutionLabStyles;
solutes;
solutionState;
// --- UI Components (HTML) ---
const container = htl.html`
<div class="solution-lab-container">
<div class="panel input-panel">
<h2 class="panel-title">Solution Lab</h2>
<p style="margin-top: -10px; font-style: italic; color: #555;">
All solutions use water as the solvent.
</p>
<p style="margin-top: -12px; font-size: 0.8rem; color: #7f8c8d;">
<i>(Note: For solubility calculations, the density of water is assumed to be 1.00 g/mL)</i>
</p>
<div class="form-group">
<label for="solute-select">1. Select a Solute:</label>
<select id="solute-select" class="input-element">
${solutes.map(group => htl.html`<optgroup label="${group.group}">${group.compounds.map(c => htl.html`<option value="${c.formula}">${c.name}</option>`)}</optgroup>`)}
</select>
<div id="solute-info" class="solute-info"></div>
</div>
<div class="form-group">
<label>2. Specify Solute Amount:</label>
<div class="radio-group">
<input type="radio" id="mass-radio" name="amount-type" value="mass"><label for="mass-radio">Mass</label>
<input type="radio" id="moles-radio" name="amount-type" value="moles"><label for="moles-radio">Moles</label>
</div>
<div class="input-with-unit">
<input type="number" id="amount-input" class="input-element" min="0" step="0.01"><span id="amount-unit"></span>
</div>
</div>
<div class="form-group">
<label for="volume-input">3. Set Final Solution Volume:</label>
<div class="input-with-unit">
<input type="number" id="volume-input" class="input-element" min="0" max="1000" step="0.1"><span>(mL)</span>
</div>
</div>
<button id="prepare-btn" class="action-button">Prepare Solution</button>
<div id="saturation-alert" class="saturation-alert" style="display: none;"></div>
</div>
<div class="panel output-panel" id="output-panel"></div>
</div>`;
// --- Logic ---
const soluteSelect = container.querySelector("#solute-select");
const amountInput = container.querySelector("#amount-input");
const volumeInput = container.querySelector("#volume-input");
const amountUnit = container.querySelector("#amount-unit");
const prepareBtn = container.querySelector("#prepare-btn");
const outputPanel = container.querySelector("#output-panel");
const saturationAlert = container.querySelector("#saturation-alert");
const soluteInfo = container.querySelector("#solute-info");
const updateSoluteInfo = () => {
const selectedFormula = soluteSelect.value;
const allCompounds = solutes.flatMap(g => g.compounds);
const solute = allCompounds.find(c => c.formula === selectedFormula);
let solubilityText;
if (solute.solubilityLimit === "Infinity") { solubilityText = "Miscible in water"; }
else if (solute.solubilityLimit === 0.0) { solubilityText = "Insoluble in water"; }
else { solubilityText = `${solute.solubilityLimit} g / 100 g H₂O`; }
soluteInfo.innerHTML = `Molar Mass: ${solute.molarMass} g mol<sup>−1</sup> <br> Solubility (25 °C): ${solubilityText}`;
};
const updateUIFromState = () => {
if (solutionState.solute) { soluteSelect.value = solutionState.solute.formula; }
if (solutionState.userInputType === 'mass') { container.querySelector('#mass-radio').checked = true; }
else if (solutionState.userInputType === 'moles') { container.querySelector('#moles-radio').checked = true; }
amountInput.value = Number(solutionState.userInputAmount).toFixed(2);
volumeInput.value = Number(solutionState.userInputVolume).toFixed(1);
amountUnit.textContent = `(${solutionState.userInputType === 'mass' ? 'g' : 'mol'})`;
};
const handlePrepare = () => {
const amountType = container.querySelector('input[name="amount-type"]:checked').value;
const amountValue = Math.max(0, parseFloat(amountInput.value) || 0);
const volumeValue = Math.max(0, parseFloat(volumeInput.value) || 0);
const selectedFormula = soluteSelect.value;
const allCompounds = solutes.flatMap(g => g.compounds);
const selectedSolute = allCompounds.find(c => c.formula === selectedFormula);
const countSigFigs = (numStr) => {
numStr = String(numStr);
if (parseFloat(numStr) === 0) return 1;
if (numStr.includes('e')) {
return countSigFigs(numStr.split('e')[0]);
}
let significantPart = numStr.replace('.', '');
if (Math.abs(parseFloat(numStr)) < 1) {
significantPart = significantPart.replace(/^0+/, '');
}
return significantPart.length;
};
let requestedMass, dissolvedMass, undissolvedMass = 0, moles, concentration,
saturationConcentration = null, isSaturated = false, isInsoluble = false,
limitingSigFigs = 3;
if (amountType === 'mass') { requestedMass = amountValue; }
else { requestedMass = amountValue * selectedSolute.molarMass; }
const formattedAmountStr = Number(amountInput.value).toFixed(2);
const formattedVolumeStr = Number(volumeInput.value).toFixed(1);
if (!selectedSolute.isSoluble) {
isInsoluble = true; dissolvedMass = 0; undissolvedMass = requestedMass; moles = 0; concentration = 0;
} else if (selectedSolute.solubilityLimit === "Infinity") {
const soluteSigFigs = countSigFigs(formattedAmountStr);
const volumeSigFigs = countSigFigs(formattedVolumeStr);
limitingSigFigs = Math.min(soluteSigFigs, volumeSigFigs);
dissolvedMass = requestedMass;
moles = dissolvedMass / selectedSolute.molarMass;
concentration = volumeValue > 0 ? moles / (volumeValue / 1000) : 0;
} else {
const maxDissolvableMass = (selectedSolute.solubilityLimit / 100) * volumeValue;
saturationConcentration = (maxDissolvableMass / selectedSolute.molarMass) / (volumeValue / 1000);
if (requestedMass > maxDissolvableMass) {
isSaturated = true;
const solubilitySigFigs = countSigFigs(String(selectedSolute.solubilityLimit));
const volumeSigFigs = countSigFigs(formattedVolumeStr);
limitingSigFigs = Math.min(solubilitySigFigs, volumeSigFigs);
dissolvedMass = maxDissolvableMass;
undissolvedMass = requestedMass - maxDissolvableMass;
concentration = saturationConcentration;
moles = dissolvedMass / selectedSolute.molarMass;
} else {
const soluteSigFigs = countSigFigs(formattedAmountStr);
const volumeSigFigs = countSigFigs(formattedVolumeStr);
limitingSigFigs = Math.min(soluteSigFigs, volumeSigFigs);
dissolvedMass = requestedMass;
moles = dissolvedMass / selectedSolute.molarMass;
concentration = volumeValue > 0 ? moles / (volumeValue / 1000) : 0;
}
}
let calculationLatex = null;
if (!isInsoluble && volumeValue > 0) {
const formatWithBar = (numStr) => {
numStr = String(numStr);
if (numStr.length === 1) return `\\bar{${numStr}}`;
return `${numStr.slice(0, -1)}\\bar{${numStr.slice(-1)}}`;
};
let amountForLatex, amountTypeForLatex;
if (isSaturated) {
amountForLatex = dissolvedMass.toFixed(2);
amountTypeForLatex = 'mass';
} else {
amountForLatex = formattedAmountStr;
amountTypeForLatex = amountType;
}
const soluteFormula = selectedSolute.formula.replace(/<sub>/g, '_{').replace(/<\/sub>/g, '}');
const unroundedResult = moles / (volumeValue / 1000);
const significantPart = unroundedResult.toPrecision(limitingSigFigs);
const fullStringWithGuards = unroundedResult.toPrecision(limitingSigFigs + 2);
const guardDigits = fullStringWithGuards.substring(significantPart.length);
const intermediateFormatted = formatWithBar(significantPart) + guardDigits;
const molarMass = selectedSolute.molarMass;
const molarMassFormatted = formatWithBar(String(molarMass));
const finalVolumeL = (volumeValue / 1000);
const finalVolumeLFormatted = finalVolumeL.toFixed(formattedVolumeStr.split('.')[1]?.length + 3 || 3);
const finalVolumeLWithBar = formatWithBar(finalVolumeLFormatted);
let substitutionSteps = '';
if (amountTypeForLatex === 'mass') {
substitutionSteps = `
&= m(\\mathrm{${soluteFormula}}) ~ M(\\mathrm{${soluteFormula}})^{-1} ~ V^{-1} \\\\[1.5ex]
&= (${formatWithBar(amountForLatex)}~\\mathrm{g})
\\left( \\dfrac{1~\\mathrm{mol}}{${molarMassFormatted}~\\mathrm{g}} \\right)
\\left( \\dfrac{1}{${finalVolumeLWithBar}~\\mathrm{L}} \\right)
`;
} else {
substitutionSteps = `
&= n(\\mathrm{${soluteFormula}}) ~ V^{-1} \\\\[1.5ex]
&= (${formatWithBar(amountForLatex)}~\\mathrm{mol})
\\left( \\dfrac{1}{${finalVolumeLWithBar}~\\mathrm{L}} \\right)
`;
}
calculationLatex = `
\\\[
\\begin{align*}
c(\\mathrm{${soluteFormula}}) &= n(\\mathrm{${soluteFormula}}) ~ V^{-1} \\\\[1.5ex]
${substitutionSteps} \\\\[1.5ex]
&= ${intermediateFormatted}~\\class{mjx-molar}{\\mathrm{M}} \\\\[1.5ex]
&= ${concentration.toPrecision(limitingSigFigs)}~\\class{mjx-molar}{\\mathrm{M}}
\\end{align*}
\\\]
`;
}
mutable solutionState = ({
userInputType: amountType, userInputAmount: amountValue, userInputVolume: volumeValue,
solute: selectedSolute, requestedMass, dissolvedMass, undissolvedMass, moles,
volume: volumeValue, concentration, saturationConcentration,
isInsoluble, isSaturated, isPrepared: true,
calculationLatex, limitingSigFigs
});
};
const renderOutput = () => {
outputPanel.innerHTML = '';
if (!solutionState.isPrepared) {
outputPanel.innerHTML = `<div class="placeholder">Your prepared solution will appear here.</div>`;
return;
}
const { solute, volume, concentration, isInsoluble, isSaturated, dissolvedMass, undissolvedMass, requestedMass, moles, saturationConcentration, limitingSigFigs } = solutionState;
if (isSaturated) {
saturationAlert.style.display = 'block';
const neededVolume = (undissolvedMass / solute.solubilityLimit) * 100;
saturationAlert.innerHTML = `<strong>Saturated Solution:</strong> The solution is saturated and <strong>${undissolvedMass.toFixed(2)} g</strong> of solid has not dissolved. To dissolve this, you would need to add at least <strong>${neededVolume.toFixed(1)} mL</strong> more water.`;
} else {
saturationAlert.style.display = 'none';
}
const svgWidth = 250, svgHeight = 225;
const svg = d3.create("svg").attr("viewBox", [0, 0, svgWidth, svgHeight]).attr("style", `width: 100%; height: auto; max-width: ${svgWidth}px;`);
const beakerBodyWidth = 120, beakerBodyHeight = 180, beakerX = (svgWidth - beakerBodyWidth) / 2, beakerY = 25;
const beakerStrokeWidth = 2.5, liquidPadding = 3.0;
svg.append("path").attr("d", `M ${beakerX + beakerBodyWidth + 10} ${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 innerWallOffset = beakerStrokeWidth / 2;
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, saturationConcentration || 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 (isInsoluble) {
liquid.attr("fill", "#aed6f1").attr("fill-opacity", 0.3);
} else {
liquid.attr("fill", solute.color).attr("fill-opacity", opacityScale(concentration));
}
if (isSaturated || isInsoluble) {
for (let i = 0; i < 35; i++) {
svg.append("circle")
.attr("cx", beakerX + 15 + Math.random() * (beakerBodyWidth - 30))
.attr("cy", beakerY + beakerBodyHeight - 7 - Math.random() * 15)
.attr("r", 1.5 + Math.random() * 2)
.attr("fill", solute.color)
.attr("stroke", "rgba(0,0,0,0.2)")
.attr("stroke-width", 1);
}
}
outputPanel.append(svg.node());
const tableNode = htl.html`<div></div>`;
let tableHTML = `
<table class="results-table"><tbody>
<tr><th>Solute</th><td>${solute.name} (${solute.formula})</td></tr>
<tr><th>Solvent</th><td>Water (H<sub>2</sub>O)</td></tr>
<tr><th>Total Mass Added</th><td>${requestedMass.toFixed(2)} g</td></tr>
${isSaturated || isInsoluble ? `<tr><th>Undissolved Solute</th><td>${undissolvedMass.toFixed(2)} g</td></tr>` : ''}
<tr><th>Dissolved Mass</th><td>${dissolvedMass.toFixed(2)} g</td></tr>
<tr><th>Dissolved Moles</th><td>${moles.toPrecision(limitingSigFigs)} mol</td></tr>
<tr><th>Final Volume</th><td>${volume.toFixed(1)} mL</td></tr>
<tr><th>Concentration</th><td><b>${isInsoluble ? "Insoluble" : (isSaturated ? `${concentration.toPrecision(limitingSigFigs)} <span class='unit-molar'>M</span> (Saturated)` : `${concentration.toPrecision(limitingSigFigs)} <span class='unit-molar'>M</span>`)}</b></td></tr>
</tbody></table>`;
if (!isInsoluble && solute.ions && solute.ions.length > 0) {
let ionRowsHTML = '<tr><td colspan="2" style="padding-top: 10px; font-weight: bold; border-bottom: none;">Ion Concentrations:</td></tr>';
solute.ions.forEach(ion => {
const ionConcentration = concentration * ion.ratio;
ionRowsHTML += `<tr><th style="padding-left: 20px;">[${ion.formula}]</th><td>${ionConcentration.toPrecision(limitingSigFigs)} <span class='unit-molar'>M</span></td></tr>`;
});
tableHTML = tableHTML.replace('</tbody>', ionRowsHTML + '</tbody>');
}
tableNode.innerHTML = tableHTML;
tableNode.querySelector("td").innerHTML = `${solute.name} (${solute.formula})`;
outputPanel.append(tableNode);
if (solutionState.calculationLatex) {
const mathPanel = htl.html`<div class="calculation-panel">
<h4>Concentration Calculation</h4>
</div>`;
const mathContent = document.createElement('div');
mathContent.innerHTML = solutionState.calculationLatex;
mathPanel.append(mathContent);
outputPanel.append(mathPanel);
if (window.MathJax) {
setTimeout(() => {
window.MathJax.typesetPromise([mathContent]);
}, 0);
}
}
};
// --- Attach Event Listeners ---
soluteSelect.addEventListener('change', updateSoluteInfo);
[amountInput, volumeInput].forEach(input => {
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
const activeInputId = event.target.id;
prepareBtn.click();
setTimeout(() => {
const newLiveInput = document.querySelector(`#${activeInputId}`);
if (newLiveInput) {
newLiveInput.select();
}
}, 0);
}
});
});
container.querySelectorAll('input[name="amount-type"]').forEach(radio => {
radio.addEventListener('change', (event) => {
amountUnit.textContent = event.target.value === 'mass' ? '(g)' : 'mol';
});
});
prepareBtn.addEventListener('click', handlePrepare);
// --- Final Setup ---
updateUIFromState();
updateSoluteInfo();
renderOutput();
return container;
}