Last active
February 27, 2026 10:25
-
-
Save ayn/29d3d219c7f57c41a809722f4e6579fc to your computer and use it in GitHub Desktop.
water-chemistry-calculator by ryan m
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Water Chemistry Calculator</title> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| min-height: 100vh; | |
| padding: 20px; | |
| } | |
| .container { | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| background: white; | |
| border-radius: 12px; | |
| box-shadow: 0 20px 60px rgba(0,0,0,0.3); | |
| overflow: hidden; | |
| } | |
| header { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| padding: 30px; | |
| text-align: center; | |
| } | |
| h1 { | |
| font-size: 2em; | |
| } | |
| .tabs { | |
| display: flex; | |
| background: #f8f9fa; | |
| border-bottom: 2px solid #dee2e6; | |
| } | |
| .tab { | |
| flex: 1; | |
| padding: 15px; | |
| text-align: center; | |
| cursor: pointer; | |
| background: #f8f9fa; | |
| border: none; | |
| font-size: 1em; | |
| font-weight: 600; | |
| color: #495057; | |
| transition: all 0.3s; | |
| } | |
| .tab:hover { | |
| background: #e9ecef; | |
| } | |
| .tab.active { | |
| background: white; | |
| color: #667eea; | |
| border-bottom: 3px solid #667eea; | |
| } | |
| .tab-content { | |
| display: none; | |
| padding: 30px; | |
| } | |
| .tab-content.active { | |
| display: block; | |
| } | |
| .section { | |
| margin-bottom: 30px; | |
| } | |
| .section-title { | |
| font-size: 1.3em; | |
| color: #333; | |
| margin-bottom: 15px; | |
| padding-bottom: 10px; | |
| border-bottom: 2px solid #667eea; | |
| } | |
| .mineral-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); | |
| gap: 10px; | |
| margin-bottom: 20px; | |
| } | |
| .mineral-checkbox { | |
| display: flex; | |
| align-items: center; | |
| padding: 10px; | |
| background: #f8f9fa; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| transition: background 0.2s; | |
| } | |
| .mineral-checkbox:hover { | |
| background: #e9ecef; | |
| } | |
| .mineral-checkbox input { | |
| margin-right: 8px; | |
| cursor: pointer; | |
| } | |
| .mineral-checkbox label { | |
| cursor: pointer; | |
| font-size: 0.9em; | |
| } | |
| .input-group { | |
| margin-bottom: 20px; | |
| } | |
| .input-group label { | |
| display: block; | |
| margin-bottom: 8px; | |
| font-weight: 600; | |
| color: #495057; | |
| } | |
| .input-group input { | |
| width: 100%; | |
| padding: 12px; | |
| border: 2px solid #dee2e6; | |
| border-radius: 6px; | |
| font-size: 1em; | |
| transition: border-color 0.3s; | |
| } | |
| .input-group input:focus { | |
| outline: none; | |
| border-color: #667eea; | |
| } | |
| .input-row { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
| gap: 15px; | |
| } | |
| button { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| border: none; | |
| padding: 15px 30px; | |
| font-size: 1em; | |
| font-weight: 600; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| transition: transform 0.2s, box-shadow 0.2s; | |
| width: 100%; | |
| } | |
| button:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4); | |
| } | |
| button:active { | |
| transform: translateY(0); | |
| } | |
| .results { | |
| margin-top: 30px; | |
| padding: 20px; | |
| background: #f8f9fa; | |
| border-radius: 8px; | |
| display: none; | |
| } | |
| .results.show { | |
| display: block; | |
| } | |
| .results-title { | |
| font-size: 1.2em; | |
| font-weight: 600; | |
| margin-bottom: 15px; | |
| color: #333; | |
| } | |
| .recipe-option { | |
| background: white; | |
| padding: 20px; | |
| border-radius: 8px; | |
| margin-bottom: 20px; | |
| border-left: 4px solid #667eea; | |
| } | |
| .option-title { | |
| font-size: 1.1em; | |
| font-weight: 600; | |
| color: #667eea; | |
| margin-bottom: 15px; | |
| } | |
| .stat-row.miss { | |
| color: #dc3545; | |
| font-weight: 600; | |
| } | |
| .recipe-item { | |
| padding: 10px; | |
| background: #f8f9fa; | |
| margin-bottom: 8px; | |
| border-radius: 4px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .recipe-item .mineral-name { | |
| font-weight: 600; | |
| color: #333; | |
| } | |
| .recipe-item .amount { | |
| color: #667eea; | |
| font-weight: 600; | |
| } | |
| .option-stats { | |
| margin-top: 15px; | |
| padding-top: 15px; | |
| border-top: 1px solid #dee2e6; | |
| font-size: 0.9em; | |
| color: #6c757d; | |
| } | |
| .stat-row { | |
| display: flex; | |
| justify-content: space-between; | |
| margin-bottom: 5px; | |
| } | |
| .mineral-table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| margin-top: 15px; | |
| font-size: 0.85em; | |
| } | |
| .mineral-table th, | |
| .mineral-table td { | |
| padding: 10px; | |
| text-align: left; | |
| border-bottom: 1px solid #dee2e6; | |
| } | |
| .mineral-table th { | |
| background: #667eea; | |
| color: white; | |
| font-weight: 600; | |
| } | |
| .mineral-table tr:hover { | |
| background: #f8f9fa; | |
| } | |
| .error-message { | |
| background: #f8d7da; | |
| color: #721c24; | |
| padding: 15px; | |
| border-radius: 6px; | |
| margin-bottom: 15px; | |
| border-left: 4px solid #dc3545; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <header> | |
| <h1>💧 Water Chemistry Calculator</h1> | |
| </header> | |
| <div class="tabs"> | |
| <button class="tab active" onclick="switchTab('calculator')">Calculator</button> | |
| <button class="tab" onclick="switchTab('database')">Mineral Database</button> | |
| </div> | |
| <!-- Calculator Tab --> | |
| <div id="calculator" class="tab-content active"> | |
| <div class="section"> | |
| <h2 class="section-title">Available Minerals</h2> | |
| <div id="mineral-checkboxes" class="mineral-grid"></div> | |
| </div> | |
| <div class="section"> | |
| <h2 class="section-title">Target Parameters</h2> | |
| <div class="input-row"> | |
| <div class="input-group"> | |
| <label>GH (ppm)</label> | |
| <input type="number" id="target-gh" placeholder="e.g., 45" step="0.1" min="0"> | |
| </div> | |
| <div class="input-group"> | |
| <label>KH (ppm)</label> | |
| <input type="number" id="target-kh" placeholder="e.g., 15" step="0.1" min="0"> | |
| </div> | |
| <div class="input-group"> | |
| <label>SO₄:Cl Ratio</label> | |
| <input type="text" id="target-ratio" placeholder="e.g., 1:3 or 2:1" value="1:3"> | |
| </div> | |
| <div class="input-group"> | |
| <label>Volume (L)</label> | |
| <input type="number" id="volume" value="1" step="0.1" min="0.1"> | |
| </div> | |
| </div> | |
| <button onclick="calculate()">Calculate Recipes</button> | |
| </div> | |
| <div id="results" class="results"></div> | |
| </div> | |
| <!-- Database Tab --> | |
| <div id="database" class="tab-content"> | |
| <div class="section"> | |
| <h2 class="section-title">Mineral Database</h2> | |
| <table class="mineral-table" id="database-table"></table> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const MINERALS = [ | |
| { | |
| name: 'Magnesium Chloride', | |
| formula: 'MgCl₂·6H₂O', | |
| mw: 203.30, | |
| ghFactor: 2.031, // mg compound per ppm GH | |
| khFactor: 0, | |
| caPercent: 0, | |
| mgPercent: 11.96, | |
| kPercent: 0, | |
| naPercent: 0, | |
| clPercent: 34.87, | |
| so4Percent: 0 | |
| }, | |
| { | |
| name: 'Epsom Salt', | |
| formula: 'MgSO₄·7H₂O', | |
| mw: 246.47, | |
| ghFactor: 2.463, | |
| khFactor: 0, | |
| caPercent: 0, | |
| mgPercent: 9.86, | |
| kPercent: 0, | |
| naPercent: 0, | |
| clPercent: 0, | |
| so4Percent: 38.99 | |
| }, | |
| { | |
| name: 'Gypsum', | |
| formula: 'CaSO₄·2H₂O', | |
| mw: 172.17, | |
| ghFactor: 1.720, | |
| khFactor: 0, | |
| caPercent: 23.28, | |
| mgPercent: 0, | |
| kPercent: 0, | |
| naPercent: 0, | |
| clPercent: 0, | |
| so4Percent: 55.79 | |
| }, | |
| { | |
| name: 'Calcium Chloride', | |
| formula: 'CaCl₂·2H₂O', | |
| mw: 147.01, | |
| ghFactor: 1.469, | |
| khFactor: 0, | |
| caPercent: 27.26, | |
| mgPercent: 0, | |
| kPercent: 0, | |
| naPercent: 0, | |
| clPercent: 48.23, | |
| so4Percent: 0 | |
| }, | |
| { | |
| name: 'Soda Ash', | |
| formula: 'Na₂CO₃', | |
| mw: 105.99, | |
| ghFactor: 0, | |
| khFactor: 1.059, | |
| caPercent: 0, | |
| mgPercent: 0, | |
| kPercent: 0, | |
| naPercent: 43.38, | |
| clPercent: 0, | |
| so4Percent: 0 | |
| }, | |
| { | |
| name: 'Potassium Carbonate', | |
| formula: 'K₂CO₃', | |
| mw: 138.21, | |
| ghFactor: 0, | |
| khFactor: 1.381, | |
| caPercent: 0, | |
| mgPercent: 0, | |
| kPercent: 56.57, | |
| naPercent: 0, | |
| clPercent: 0, | |
| so4Percent: 0 | |
| }, | |
| { | |
| name: 'Baking Soda', | |
| formula: 'NaHCO₃', | |
| mw: 84.01, | |
| ghFactor: 0, | |
| khFactor: 1.679, | |
| caPercent: 0, | |
| mgPercent: 0, | |
| kPercent: 0, | |
| naPercent: 27.37, | |
| clPercent: 0, | |
| so4Percent: 0 | |
| }, | |
| { | |
| name: 'Potassium Bicarbonate', | |
| formula: 'KHCO₃', | |
| mw: 100.12, | |
| ghFactor: 0, | |
| khFactor: 2.000, | |
| caPercent: 0, | |
| mgPercent: 0, | |
| kPercent: 39.04, | |
| naPercent: 0, | |
| clPercent: 0, | |
| so4Percent: 0 | |
| }, | |
| { | |
| name: 'Potassium Chloride', | |
| formula: 'KCl', | |
| mw: 74.55, | |
| ghFactor: 0, | |
| khFactor: 0, | |
| caPercent: 0, | |
| mgPercent: 0, | |
| kPercent: 52.44, | |
| naPercent: 0, | |
| clPercent: 47.56, | |
| so4Percent: 0 | |
| }, | |
| { | |
| name: 'Potassium Sulfate', | |
| formula: 'K₂SO₄', | |
| mw: 174.26, | |
| ghFactor: 0, | |
| khFactor: 0, | |
| caPercent: 0, | |
| mgPercent: 0, | |
| kPercent: 44.87, | |
| naPercent: 0, | |
| clPercent: 0, | |
| so4Percent: 55.13 | |
| }, | |
| { | |
| name: 'Sodium Chloride', | |
| formula: 'NaCl', | |
| mw: 58.44, | |
| ghFactor: 0, | |
| khFactor: 0, | |
| caPercent: 0, | |
| mgPercent: 0, | |
| kPercent: 0, | |
| naPercent: 39.34, | |
| clPercent: 60.66, | |
| so4Percent: 0 | |
| }, | |
| { | |
| name: 'Calcium Carbonate', | |
| formula: 'CaCO₃', | |
| mw: 100.09, | |
| ghFactor: 1.000, | |
| khFactor: 1.000, | |
| caPercent: 40.04, | |
| mgPercent: 0, | |
| kPercent: 0, | |
| naPercent: 0, | |
| clPercent: 0, | |
| so4Percent: 0 | |
| } | |
| ]; | |
| let selectedMinerals = new Set(); | |
| function initApp() { | |
| renderMineralCheckboxes(); | |
| renderDatabaseTable(); | |
| selectAllMinerals(); | |
| } | |
| function selectAllMinerals() { | |
| const uncheckedByDefault = ['Soda Ash', 'Potassium Sulfate', 'Gypsum']; | |
| MINERALS.forEach((mineral, idx) => { | |
| if (!uncheckedByDefault.includes(mineral.name)) { | |
| selectedMinerals.add(idx); | |
| } | |
| }); | |
| document.querySelectorAll('.mineral-checkbox input').forEach((cb, idx) => { | |
| cb.checked = !uncheckedByDefault.includes(MINERALS[idx].name); | |
| }); | |
| } | |
| function renderMineralCheckboxes() { | |
| const container = document.getElementById('mineral-checkboxes'); | |
| MINERALS.forEach((mineral, idx) => { | |
| const div = document.createElement('div'); | |
| div.className = 'mineral-checkbox'; | |
| const checkbox = document.createElement('input'); | |
| checkbox.type = 'checkbox'; | |
| checkbox.id = `mineral-${idx}`; | |
| checkbox.checked = true; | |
| checkbox.onchange = () => toggleMineral(idx); | |
| const label = document.createElement('label'); | |
| label.htmlFor = `mineral-${idx}`; | |
| label.textContent = mineral.name; | |
| div.appendChild(checkbox); | |
| div.appendChild(label); | |
| container.appendChild(div); | |
| }); | |
| } | |
| function toggleMineral(idx) { | |
| if (selectedMinerals.has(idx)) { | |
| selectedMinerals.delete(idx); | |
| } else { | |
| selectedMinerals.add(idx); | |
| } | |
| } | |
| function renderDatabaseTable() { | |
| const table = document.getElementById('database-table'); | |
| let html = '<thead><tr><th>Name</th><th>Formula</th><th>MW</th><th>mg/L per ppm GH</th><th>mg/L per ppm KH</th><th>Ca %</th><th>Mg %</th><th>K %</th><th>Na %</th><th>Cl %</th><th>SO₄ %</th></tr></thead><tbody>'; | |
| MINERALS.forEach(mineral => { | |
| html += '<tr>'; | |
| html += `<td><strong>${mineral.name}</strong></td>`; | |
| html += `<td>${mineral.formula}</td>`; | |
| html += `<td>${mineral.mw.toFixed(2)}</td>`; | |
| html += `<td>${mineral.ghFactor.toFixed(3)}</td>`; | |
| html += `<td>${mineral.khFactor.toFixed(3)}</td>`; | |
| html += `<td>${mineral.caPercent.toFixed(2)}%</td>`; | |
| html += `<td>${mineral.mgPercent.toFixed(2)}%</td>`; | |
| html += `<td>${mineral.kPercent.toFixed(2)}%</td>`; | |
| html += `<td>${mineral.naPercent.toFixed(2)}%</td>`; | |
| html += `<td>${mineral.clPercent.toFixed(2)}%</td>`; | |
| html += `<td>${mineral.so4Percent.toFixed(2)}%</td>`; | |
| html += '</tr>'; | |
| }); | |
| html += '</tbody>'; | |
| table.innerHTML = html; | |
| } | |
| function switchTab(tabName) { | |
| document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active')); | |
| document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active')); | |
| event.target.classList.add('active'); | |
| document.getElementById(tabName).classList.add('active'); | |
| } | |
| function parseRatio(ratioStr) { | |
| const parts = ratioStr.split(':').map(p => parseFloat(p.trim())); | |
| if (parts.length !== 2 || parts.some(isNaN)) { | |
| return null; | |
| } | |
| return parts[0] / parts[1]; // SO4/Cl ratio | |
| } | |
| function calculate() { | |
| const targetGH = parseFloat(document.getElementById('target-gh').value) || 0; | |
| const targetKH = parseFloat(document.getElementById('target-kh').value) || 0; | |
| const ratioStr = document.getElementById('target-ratio').value; | |
| const volume = parseFloat(document.getElementById('volume').value) || 1; | |
| if (selectedMinerals.size === 0) { | |
| showError('Please select at least one mineral.'); | |
| return; | |
| } | |
| if (targetGH === 0 && targetKH === 0) { | |
| showError('Please enter target GH and/or KH values.'); | |
| return; | |
| } | |
| const targetRatio = parseRatio(ratioStr); | |
| if (targetRatio === null) { | |
| showError('Invalid SO₄:Cl ratio format. Use format like "1:3" or "2:1".'); | |
| return; | |
| } | |
| const availableMinerals = Array.from(selectedMinerals).map(idx => MINERALS[idx]); | |
| const recipes = findRecipes(targetGH, targetKH, targetRatio, availableMinerals, volume); | |
| displayResults(recipes, targetGH, targetKH, targetRatio, volume); | |
| } | |
| function findRecipes(targetGH, targetKH, targetRatio, minerals, volume) { | |
| const recipes = []; | |
| // Get mineral categories | |
| const ghSources = minerals.filter(m => m.ghFactor > 0); | |
| const khSources = minerals.filter(m => m.khFactor > 0); | |
| const clSources = minerals.filter(m => m.clPercent > 0); | |
| const so4Sources = minerals.filter(m => m.so4Percent > 0); | |
| // Generate all possible GH combinations (1-3 minerals) | |
| const ghCombos = []; | |
| // Single GH sources | |
| ghSources.forEach(m1 => ghCombos.push([m1])); | |
| // Pairs of GH sources | |
| for (let i = 0; i < ghSources.length; i++) { | |
| for (let j = i + 1; j < ghSources.length; j++) { | |
| ghCombos.push([ghSources[i], ghSources[j]]); | |
| } | |
| } | |
| // Triples of GH sources | |
| for (let i = 0; i < ghSources.length; i++) { | |
| for (let j = i + 1; j < ghSources.length; j++) { | |
| for (let k = j + 1; k < ghSources.length; k++) { | |
| ghCombos.push([ghSources[i], ghSources[j], ghSources[k]]); | |
| } | |
| } | |
| } | |
| // Generate all possible KH combinations (1-3 minerals) | |
| const khCombos = []; | |
| // Single KH sources | |
| khSources.forEach(m1 => khCombos.push([m1])); | |
| // Pairs of KH sources | |
| for (let i = 0; i < khSources.length; i++) { | |
| for (let j = i + 1; j < khSources.length; j++) { | |
| khCombos.push([khSources[i], khSources[j]]); | |
| } | |
| } | |
| // Triples of KH sources | |
| for (let i = 0; i < khSources.length; i++) { | |
| for (let j = i + 1; j < khSources.length; j++) { | |
| for (let k = j + 1; k < khSources.length; k++) { | |
| khCombos.push([khSources[i], khSources[j], khSources[k]]); | |
| } | |
| } | |
| } | |
| // Generate ratio adjuster combinations (0-2 minerals) | |
| const ratioAdjusters = [[]]; | |
| // Single adjusters | |
| [...clSources, ...so4Sources].forEach(adj => ratioAdjusters.push([adj])); | |
| // Pairs of adjusters | |
| for (const so4 of so4Sources) { | |
| for (const cl of clSources) { | |
| if (so4.name !== cl.name) { | |
| ratioAdjusters.push([so4, cl]); | |
| } | |
| } | |
| } | |
| // Try all combinations | |
| for (const ghCombo of ghCombos) { | |
| for (const khCombo of khCombos) { | |
| for (const adjusters of ratioAdjusters) { | |
| // Count unique minerals | |
| const allMinerals = new Set([ | |
| ...ghCombo.map(m => m.name), | |
| ...khCombo.map(m => m.name), | |
| ...adjusters.map(m => m.name) | |
| ]); | |
| // Only try if we have 5 or fewer unique minerals | |
| if (allMinerals.size <= 5) { | |
| const recipe = tryRecipeMulti(ghCombo, khCombo, adjusters, targetGH, targetKH, targetRatio); | |
| if (recipe && isValidRecipe(recipe)) { | |
| const stats = calculateStats(recipe); | |
| recipes.push({ recipe, stats }); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Sort by total deviation and remove duplicates | |
| return recipes | |
| .sort((a, b) => { | |
| // Prioritize GH/KH match first | |
| const ghkhDiff = Math.abs(a.stats.deviation - b.stats.deviation); | |
| if (ghkhDiff > 0.5) { | |
| return a.stats.deviation - b.stats.deviation; | |
| } | |
| // Then sort by ratio deviation | |
| const aRatioDev = Math.abs(a.stats.ratio - targetRatio); | |
| const bRatioDev = Math.abs(b.stats.ratio - targetRatio); | |
| if (Math.abs(aRatioDev - bRatioDev) > 0.1) { | |
| return aRatioDev - bRatioDev; | |
| } | |
| // Finally prefer recipes with more diversity (Ca vs Mg) | |
| const aHasCa = a.stats.ca > 0; | |
| const bHasCa = b.stats.ca > 0; | |
| if (aHasCa !== bHasCa) { | |
| return aHasCa ? -1 : 1; // Prefer recipes with Ca (return negative to rank higher) | |
| } | |
| return 0; | |
| }) | |
| .slice(0, 10); | |
| } | |
| function tryRecipeMulti(ghMinerals, khMinerals, adjusters, targetGH, targetKH, targetRatio) { | |
| const recipe = {}; | |
| // Split GH evenly among GH sources | |
| if (targetGH > 0 && ghMinerals.length > 0) { | |
| const ghPerSource = targetGH / ghMinerals.length; | |
| for (const m of ghMinerals) { | |
| recipe[m.name] = (recipe[m.name] || 0) + ghPerSource * m.ghFactor; | |
| } | |
| } | |
| // Split KH evenly among KH sources | |
| if (targetKH > 0 && khMinerals.length > 0) { | |
| const khPerSource = targetKH / khMinerals.length; | |
| for (const m of khMinerals) { | |
| recipe[m.name] = (recipe[m.name] || 0) + khPerSource * m.khFactor; | |
| } | |
| } | |
| // Calculate current SO4 and Cl from base minerals | |
| let so4 = 0, cl = 0; | |
| for (const [name, amount] of Object.entries(recipe)) { | |
| const mineral = MINERALS.find(m => m.name === name); | |
| if (mineral) { | |
| so4 += amount * mineral.so4Percent / 100; | |
| cl += amount * mineral.clPercent / 100; | |
| } | |
| } | |
| // Add ratio adjusters if provided | |
| if (adjusters.length > 0) { | |
| // If we have both SO4 and Cl adjusters, optimize the amounts | |
| const so4Adjusters = adjusters.filter(a => a.so4Percent > 0); | |
| const clAdjusters = adjusters.filter(a => a.clPercent > 0); | |
| if (so4Adjusters.length > 0 && clAdjusters.length === 0) { | |
| // Only SO4 adjuster - add amount to hit ratio | |
| if (cl > 0) { | |
| const targetSO4 = cl * targetRatio; | |
| const so4Needed = targetSO4 - so4; | |
| if (so4Needed > 0) { | |
| const so4Adj = so4Adjusters[0]; | |
| recipe[so4Adj.name] = (recipe[so4Adj.name] || 0) + (so4Needed * 100) / so4Adj.so4Percent; | |
| } | |
| } | |
| } else if (clAdjusters.length > 0 && so4Adjusters.length === 0) { | |
| // Only Cl adjuster - add amount to hit ratio | |
| if (so4 > 0) { | |
| const targetCl = so4 / targetRatio; | |
| const clNeeded = targetCl - cl; | |
| if (clNeeded > 0) { | |
| const clAdj = clAdjusters[0]; | |
| recipe[clAdj.name] = (recipe[clAdj.name] || 0) + (clNeeded * 100) / clAdj.clPercent; | |
| } | |
| } | |
| } else if (so4Adjusters.length > 0 && clAdjusters.length > 0) { | |
| // Both SO4 and Cl adjusters - solve simultaneously | |
| // We want: (so4 + x*so4%) / (cl + y*cl%) = targetRatio | |
| // Try a simple approach: balance to hit the ratio | |
| const so4Adj = so4Adjusters[0]; | |
| const clAdj = clAdjusters[0]; | |
| // Calculate amounts needed to achieve target ratio | |
| // Using iterative approach | |
| let so4Amount = 0, clAmount = 0; | |
| const currentRatio = cl > 0 ? so4 / cl : (so4 > 0 ? Infinity : 0); | |
| if (currentRatio < targetRatio) { | |
| // Need more SO4 | |
| so4Amount = 10; // Start with small amount | |
| const newSO4 = so4 + (so4Amount * so4Adj.so4Percent / 100); | |
| const newCl = cl + (so4Amount * so4Adj.clPercent / 100); | |
| if (newCl > 0) { | |
| const targetCl = newSO4 / targetRatio; | |
| const clNeeded = targetCl - newCl; | |
| if (clNeeded > 0) { | |
| clAmount = (clNeeded * 100) / clAdj.clPercent; | |
| } | |
| } | |
| } else if (currentRatio > targetRatio) { | |
| // Need more Cl | |
| clAmount = 10; | |
| const newCl = cl + (clAmount * clAdj.clPercent / 100); | |
| const newSO4 = so4 + (clAmount * clAdj.so4Percent / 100); | |
| const targetSO4 = newCl * targetRatio; | |
| const so4Needed = targetSO4 - newSO4; | |
| if (so4Needed > 0) { | |
| so4Amount = (so4Needed * 100) / so4Adj.so4Percent; | |
| } | |
| } | |
| if (so4Amount > 0.1) recipe[so4Adj.name] = (recipe[so4Adj.name] || 0) + so4Amount; | |
| if (clAmount > 0.1) recipe[clAdj.name] = (recipe[clAdj.name] || 0) + clAmount; | |
| } | |
| } | |
| return recipe; | |
| } | |
| function tryRecipe2(ghMineral, khMineral, targetGH, targetKH) { | |
| const recipe = {}; | |
| if (targetGH > 0) { | |
| recipe[ghMineral.name] = targetGH * ghMineral.ghFactor; | |
| } | |
| if (targetKH > 0) { | |
| recipe[khMineral.name] = targetKH * khMineral.khFactor; | |
| } | |
| return recipe; | |
| } | |
| function tryRecipe3(ghMineral, khMineral, adjuster, targetGH, targetKH, targetRatio) { | |
| const recipe = {}; | |
| // Base amounts for GH and KH | |
| let ghAmount = targetGH > 0 ? targetGH * ghMineral.ghFactor : 0; | |
| let khAmount = targetKH > 0 ? targetKH * khMineral.khFactor : 0; | |
| // Calculate current SO4 and Cl from base minerals | |
| let so4 = (ghAmount * ghMineral.so4Percent / 100) + (khAmount * khMineral.so4Percent / 100); | |
| let cl = (ghAmount * ghMineral.clPercent / 100) + (khAmount * khMineral.clPercent / 100); | |
| // Determine how much adjuster to add | |
| let adjusterAmount = 0; | |
| if (adjuster.so4Percent > 0 && cl > 0) { | |
| // Need to add SO4 to match ratio: so4/cl = targetRatio | |
| const targetSO4 = cl * targetRatio; | |
| const so4Needed = targetSO4 - so4; | |
| if (so4Needed > 0) { | |
| adjusterAmount = (so4Needed * 100) / adjuster.so4Percent; | |
| } | |
| } else if (adjuster.clPercent > 0 && so4 > 0) { | |
| // Need to add Cl to match ratio: so4/cl = targetRatio | |
| const targetCl = so4 / targetRatio; | |
| const clNeeded = targetCl - cl; | |
| if (clNeeded > 0) { | |
| adjusterAmount = (clNeeded * 100) / adjuster.clPercent; | |
| } | |
| } | |
| if (ghAmount > 0) recipe[ghMineral.name] = ghAmount; | |
| if (khAmount > 0) recipe[khMineral.name] = khAmount; | |
| if (adjusterAmount > 0.1) recipe[adjuster.name] = adjusterAmount; | |
| return recipe; | |
| } | |
| function isValidRecipe(recipe) { | |
| return Object.values(recipe).every(amount => amount > 0 && amount < 10000); | |
| } | |
| function calculateStats(recipe) { | |
| let gh = 0, kh = 0, ca = 0, mg = 0, k = 0, na = 0, so4 = 0, cl = 0; | |
| for (const [name, amount] of Object.entries(recipe)) { | |
| const mineral = MINERALS.find(m => m.name === name); | |
| if (mineral) { | |
| gh += mineral.ghFactor > 0 ? amount / mineral.ghFactor : 0; | |
| kh += mineral.khFactor > 0 ? amount / mineral.khFactor : 0; | |
| ca += amount * mineral.caPercent / 100; | |
| mg += amount * mineral.mgPercent / 100; | |
| k += amount * mineral.kPercent / 100; | |
| na += amount * mineral.naPercent / 100; | |
| so4 += amount * mineral.so4Percent / 100; | |
| cl += amount * mineral.clPercent / 100; | |
| } | |
| } | |
| const ratio = cl > 0 ? so4 / cl : (so4 > 0 ? Infinity : 0); | |
| const deviation = Math.abs(gh - (parseFloat(document.getElementById('target-gh').value) || 0)) + | |
| Math.abs(kh - (parseFloat(document.getElementById('target-kh').value) || 0)); | |
| return { gh, kh, ca, mg, k, na, so4, cl, ratio, deviation }; | |
| } | |
| function displayResults(recipes, targetGH, targetKH, targetRatio, volume) { | |
| const container = document.getElementById('results'); | |
| container.classList.add('show'); | |
| if (recipes.length === 0) { | |
| container.innerHTML = '<div class="error-message">No valid recipes found with selected minerals.</div>'; | |
| return; | |
| } | |
| let html = '<h3 class="results-title">Recipe Options</h3>'; | |
| recipes.forEach((item, idx) => { | |
| // Check if recipe meets targets (within tolerance) | |
| const ghTolerance = targetGH * 0.05; // 5% | |
| const khTolerance = targetKH * 0.05; // 5% | |
| const ratioTolerance = targetRatio * 0.15; // 15% | |
| const ghMiss = Math.abs(item.stats.gh - targetGH) > Math.max(ghTolerance, 1); | |
| const khMiss = Math.abs(item.stats.kh - targetKH) > Math.max(khTolerance, 1); | |
| const ratioMiss = item.stats.cl > 0 && Math.abs(item.stats.ratio - targetRatio) > Math.max(ratioTolerance, 0.1); | |
| html += `<div class="recipe-option">`; | |
| html += `<div class="option-title">Option ${idx + 1}</div>`; | |
| for (const [name, amount] of Object.entries(item.recipe)) { | |
| const perLiter = amount.toFixed(1); | |
| const total = (amount * volume).toFixed(1); | |
| html += `<div class="recipe-item"> | |
| <span class="mineral-name">${name}</span> | |
| <span class="amount">${perLiter} mg/L (${total} mg total)</span> | |
| </div>`; | |
| } | |
| html += '<div class="option-stats">'; | |
| html += `<div class="stat-row ${ghMiss ? 'miss' : ''}"><span>Actual GH:</span><span>${item.stats.gh.toFixed(1)} ppm (target: ${targetGH})</span></div>`; | |
| html += `<div class="stat-row ${khMiss ? 'miss' : ''}"><span>Actual KH:</span><span>${item.stats.kh.toFixed(1)} ppm (target: ${targetKH})</span></div>`; | |
| html += `<div class="stat-row"><span>Ca²⁺:</span><span>${item.stats.ca.toFixed(1)} mg/L</span></div>`; | |
| html += `<div class="stat-row"><span>Mg²⁺:</span><span>${item.stats.mg.toFixed(1)} mg/L</span></div>`; | |
| html += `<div class="stat-row"><span>K⁺:</span><span>${item.stats.k.toFixed(1)} mg/L</span></div>`; | |
| html += `<div class="stat-row"><span>Na⁺:</span><span>${item.stats.na.toFixed(1)} mg/L</span></div>`; | |
| html += `<div class="stat-row"><span>SO₄²⁻:</span><span>${item.stats.so4.toFixed(1)} mg/L</span></div>`; | |
| html += `<div class="stat-row"><span>Cl⁻:</span><span>${item.stats.cl.toFixed(1)} mg/L</span></div>`; | |
| if (item.stats.cl > 0) { | |
| html += `<div class="stat-row ${ratioMiss ? 'miss' : ''}"><span>SO₄:Cl Ratio:</span><span>${(item.stats.so4).toFixed(1)}:${(item.stats.cl).toFixed(1)} (${(item.stats.ratio).toFixed(2)}:1, target: ${targetRatio.toFixed(2)}:1)</span></div>`; | |
| } else if (item.stats.so4 > 0) { | |
| html += `<div class="stat-row"><span>SO₄:Cl Ratio:</span><span>SO₄ only (no Cl)</span></div>`; | |
| } | |
| html += '</div>'; | |
| html += '</div>'; | |
| }); | |
| container.innerHTML = html; | |
| } | |
| function showError(message) { | |
| const container = document.getElementById('results'); | |
| container.classList.add('show'); | |
| container.innerHTML = `<div class="error-message">${message}</div>`; | |
| } | |
| initApp(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment