Chem.Academy
  • Home
  • Book
  • Notes
  • Skill Hub
  • Quick Reference
  • Periodic Table

Welcome! This online textbook is a living project.
Content is being added and refined weekly as we build a complete resource for General Chemistry I & II. Thank you for visiting!

  • Overview
  • Skill Drills
    • Nomenclature
    • Solubility Rules
    • Acids and Bases
  • Learning Labs
    • Solution
    • Solution Mixing Lab
// =============================================================================
// 1. DATA (Colors Updated for Chemical Accuracy)
// =============================================================================
solutes = [
  {
    "group": "Common Salts (Ionic)",
    "compounds": [
      { "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>&minus;</sup>", "ratio": 1 }] },
      { "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>&minus;</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&minus;</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>&minus;</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>&minus;</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&minus;</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>&minus;</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>&minus;</sup>", "ratio": 1 }] },
      { "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>&minus;</sup>", "ratio": 2 }] },
      { "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>&minus;</sup>", "ratio": 2 }] },
      { "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&minus;</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": "#ecf0f1", "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": "#ecf0f1", "ions": [] }
    ]
  },
  {
    "group": "Acids & Bases",
    "compounds": [
      { "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>&minus;</sup>", "ratio": 1 }] },
      { "name": "sodium hydroxide (strong)", "formula": "NaOH", "molarMass": 40.00, "isSoluble": true, "solubilityLimit": "Infinity", "color": "#ecf0f1", "ions": [{ "formula": "Na<sup>+</sup>", "ratio": 1 }, { "formula": "OH<sup>&minus;</sup>", "ratio": 1 }] },
      { "name": "acetic acid (weak)", "formula": "CH<sub>3</sub>COOH", "molarMass": 60.05, "isSoluble": true, "solubilityLimit": "Infinity", "color": "#ecf0f1", "ions": [] },
      { "name": "ammonia (weak base)", "formula": "NH<sub>3</sub>", "molarMass": 17.03, "isSoluble": true, "solubilityLimit": "Infinity", "color": "#ecf0f1", "ions": [] }
    ]
  }
]
// =============================================================================
// 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.8
      },
      // 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: 325; /* 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: 275px; 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: 275px; 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: 275px; 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;
      background-color: #fff; /* This ensures no background color is applied */
    }
    .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: 275px; 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;
      font-weight: 500 !important;
      transform: translateY(-0.000em);
    }
    .unit-molar {
      font-variant-caps: small-caps;
      font-size: 0.86em !important;
      font-weight: 500;
    }
    b .unit-molar {
      font-weight: 700;
    }
    .dissolution-process {
      width: 100%;
      text-align: center;
      margin: 15px 0;
      font-size: 1.0em;
      padding: 5px;
    }
    @media (max-width: 768px) {
      .solution-lab-container {
        flex-direction: column;
        padding: 15px;
      }
      .input-panel, .output-panel {
        flex: 1 1 100%;
        width: 100%;
        min-width: 0;
      }
    }
</style>`
// =============================================================================
// 3. REACTIVE STATE (Updated to preserve input string)
// =============================================================================
mutable solutionState = ({
    // User Input State
    userInputType: null,
    userInputAmount: null,
    userInputAmountString: null, // <-- ADD THIS LINE
    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,

    // 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 = 100.0;
    const requestedMass = defaultAmount;
    const dissolvedMass = defaultAmount;
    const moles = requestedMass / defaultSolute.molarMass;
    const concentration = moles / (defaultVolume / 1000);
    const limitingSigFigs = 4;

    mutable solutionState = ({
      userInputType: 'mass',
      userInputAmount: defaultAmount,
      userInputAmountString: defaultAmount.toFixed(
        2), // <-- ADD THIS LINE
      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 
// =============================================================================
{
  mathjax_dependency;
  solutionLabStyles;
  solutes;
  solutionState;

  // --- UI Components (HTML with Molarity Radio Button) ---
  const container = htl.html`
  <div class="solution-lab-container">
    <div class="panel input-panel">
      <h2 class="panel-title">Solution Builder</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>&rho;</i>(H<sub>2</sub>O) = 1.00 g mL<sup>&minus;1</sup> for all relevant calculations.
      </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 or Final Concentration:</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>
          <input type="radio" id="molarity-radio" name="amount-type" value="molarity"><label for="molarity-radio">Molarity</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");

  /**
   * Rounds a number using the "round half to even" (banker's rounding) rule.
   * @param {number} value The number to round.
   * @returns {number} The rounded number.
   */
  const roundHalfToEven = (value) => {
    const floor = Math.floor(value);
    const diff = value - floor;
    if (diff < 0.5) return floor;
    if (diff > 0.5) return floor + 1;
    // It's exactly 0.5
    return (floor % 2 === 0) ? floor : floor + 1;
  };

  /**
   * Formats a number to a specific number of significant figures using banker's rounding.
   * @param {number} num The number to format.
   * @param {number} sigFigs The number of significant figures.
   * @returns {string} The formatted number as a string.
   */
  const formatToSigFigs = (num, sigFigs) => {
    if (num === 0 || isNaN(num) || sigFigs < 1) {
      return (0).toPrecision(sigFigs);
    }
    const magnitude = Math.floor(Math.log10(Math.abs(num)));
    const scale = Math.pow(10, sigFigs - magnitude - 1);
    const scaledNum = num * scale;
    const roundedScaledNum = roundHalfToEven(scaledNum);
    const roundedNum = roundedScaledNum / scale;
    return roundedNum.toPrecision(sigFigs);
  };

  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>&minus;1</sup> <br> Solubility (25 °C): ${solubilityText}`;
  };

  const updateUIFromState = () => {
    if (solutionState.solute) {
      soluteSelect.value = solutionState.solute.formula;
    }

    // This correctly uses the saved string, preserving trailing zeros
    amountInput.value = solutionState.userInputAmountString;

    if (solutionState.userInputType === 'mass') {
      container.querySelector('#mass-radio').checked = true;
      amountInput.step = "0.01";
      amountUnit.textContent = `(g)`;
    } else if (solutionState.userInputType === 'moles') {
      container.querySelector('#moles-radio').checked = true;
      amountInput.step = "0.0001"; // Changed for 4 decimal places
      amountUnit.textContent = `(mol)`;
    } else if (solutionState.userInputType === 'molarity') {
      container.querySelector('#molarity-radio').checked = true;
      amountInput.step = "0.0001"; // Changed for 4 decimal places
      amountUnit.textContent = `(M)`;
    }

    volumeInput.value = Number(solutionState.userInputVolume).toFixed(
      1);
  };

  const handlePrepare = () => {
    const amountType = container.querySelector(
      'input[name="amount-type"]:checked').value;
    const amountValue = Math.max(0, parseFloat(amountInput.value) || 0);
    const amountValueString = amountInput
      .value; // <-- Capture the raw string
    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 if (amountType === 'moles') {
      requestedMass = amountValue * selectedSolute.molarMass;
    } else if (amountType === 'molarity') {
      const molesNeeded = amountValue * (volumeValue / 1000);
      requestedMass = molesNeeded * selectedSolute.molarMass;
    }

    let soluteSigFigStr = amountInput.value;
    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(soluteSigFigStr);
      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(soluteSigFigStr);
        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)}}`;
      };

      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 (amountType === 'mass') {
        substitutionSteps = `
    &= m(\\mathrm{${soluteFormula}}) ~ M(\\mathrm{${soluteFormula}})^{-1} ~ V^{-1} \\\\[1.5ex]
    &= (${formatWithBar(soluteSigFigStr)}~\\mathrm{g})
       \\left( \\dfrac{1~\\mathrm{mol}}{${molarMassFormatted}~\\mathrm{g}} \\right)
       \\left( \\dfrac{1}{${finalVolumeLWithBar}~\\mathrm{L}} \\right)
    `;
      } else if (amountType === 'moles') {
        substitutionSteps = `
    &= n(\\mathrm{${soluteFormula}}) ~ V^{-1} \\\\[1.5ex]
    &= (${formatWithBar(soluteSigFigStr)}~\\mathrm{mol})
       \\left( \\dfrac{1}{${finalVolumeLWithBar}~\\mathrm{L}} \\right)
    `;
      } else { // Molarity input type
        substitutionSteps = `
    &= m(\\mathrm{${soluteFormula}}) ~ M(\\mathrm{${soluteFormula}})^{-1} ~ V^{-1} \\\\[1.5ex]
    &= (${formatWithBar(dissolvedMass.toPrecision(limitingSigFigs))}~\\mathrm{g})
       \\left( \\dfrac{1~\\mathrm{mol}}{${molarMassFormatted}~\\mathrm{g}} \\right)
       \\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,
      userInputAmountString: amountValueString, // <-- Save the raw string in the state
      userInputVolume: volumeValue,
      solute: selectedSolute,
      requestedMass,
      dissolvedMass,
      undissolvedMass,
      moles,
      volume: volumeValue,
      concentration,
      saturationConcentration,
      isInsoluble,
      isSaturated,
      isPrepared: true,
      calculationLatex,
      limitingSigFigs
    });
  };

  const getDissolutionEquation = (solute) => {
    let equationHTML = '';
    const formula = solute.formula;

    if (!solute.isSoluble) {
      equationHTML = `${formula}(s) &rarr; No Dissolution`;
    } else if (solute.ions && solute.ions.length > 0) {
      const ionsString = solute.ions.map(ion =>
        (ion.ratio > 1 ? ion.ratio : '') + ion.formula + '(aq)'
      ).join(' + ');
      equationHTML = `${formula}(s) &rarr; ${ionsString}`;
    } else {
      equationHTML = `${formula}(s) &rarr; ${formula}(aq)`;
    }

    const containerNode = htl.html`<div class="dissolution-process"></div>`;
    containerNode.innerHTML =
      `<b>Dissolution Process:</b><br>${equationHTML}`;
    return containerNode;
  };

  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 requested amount exceeds the solubility limit. The solution is saturated, and <strong>${formatToSigFigs(undissolvedMass, limitingSigFigs)} g</strong> of solid has not dissolved.`;
    } 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 === '#ecf0f1' ? '#bdc3c7' :
            solute.color)
          .attr("stroke", "rgba(0,0,0,0.2)")
          .attr("stroke-width", 1);
      }
    }

    outputPanel.append(svg.node());

    const equationNode = getDissolutionEquation(solute);
    outputPanel.append(equationNode);

    const tableNode = htl.html`<div></div>`;

    const formattedMoles = formatToSigFigs(moles, limitingSigFigs);
    const formattedConcentration = formatToSigFigs(concentration,
      limitingSigFigs);
    const formattedRequestedMass = formatToSigFigs(requestedMass,
      limitingSigFigs);
    const formattedDissolvedMass = formatToSigFigs(dissolvedMass,
      limitingSigFigs);
    const formattedUndissolvedMass = formatToSigFigs(undissolvedMass,
      limitingSigFigs);

    let concentrationString = isInsoluble ?
      "Insoluble" :
      (isSaturated ?
        `${formattedConcentration} <span class='unit-molar'>M</span> (Saturated)` :
        `${formattedConcentration} <span class='unit-molar'>M</span>`
      );

    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>${formattedRequestedMass} g</td></tr>
        ${isSaturated || isInsoluble ? `<tr><th>Undissolved Solute</th><td>${formattedUndissolvedMass} g</td></tr>` : ''}
        <tr><th>Dissolved Mass</th><td>${formattedDissolvedMass} g</td></tr>
        <tr><th>Dissolved Moles</th><td>${formattedMoles} mol</td></tr>
        <tr><th>Final Volume</th><td>${volume.toFixed(1)} mL</td></tr>
        <tr><th>Concentration</th><td><b>${concentrationString}</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;
        const formattedIonConcentration = formatToSigFigs(
          ionConcentration, limitingSigFigs);
        ionRowsHTML +=
          `<tr><th style="padding-left: 20px;">[${ion.formula}]</th><td>${formattedIonConcentration} <span class='unit-molar'>M</span></td></tr>`;
      });
      tableHTML = tableHTML.replace('</tbody>', ionRowsHTML +
        '</tbody>');
    }

    tableNode.innerHTML = tableHTML;
    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();
        prepareBtn.click();
      }
    });
  });

  container.querySelectorAll('input[name="amount-type"]').forEach(radio => {
    radio.addEventListener('change', (event) => {
      const type = event.target.value;
      if (type === 'mass') {
        amountUnit.textContent = '(g)';
        amountInput.step = "0.01";
        amountInput.value = '10.00';
      } else if (type === 'moles') {
        amountUnit.textContent = '(mol)';
        amountInput.step = "0.0001";
        amountInput.value = '0.2500';
      } else if (type === 'molarity') {
        amountUnit.textContent = '(M)';
        amountInput.step = "0.0001";
        amountInput.value =
          '0.1000'; // Corrected default value to match mixing lab
      }
    });
  });

  prepareBtn.addEventListener('click', handlePrepare);

  // --- Final Setup ---
  updateUIFromState();
  updateSoluteInfo();
  renderOutput();

  return container;
}



 
 

© Copyright 2025