Skip to content

Instantly share code, notes, and snippets.

@ayn
Last active February 27, 2026 10:25
Show Gist options
  • Select an option

  • Save ayn/29d3d219c7f57c41a809722f4e6579fc to your computer and use it in GitHub Desktop.

Select an option

Save ayn/29d3d219c7f57c41a809722f4e6579fc to your computer and use it in GitHub Desktop.
water-chemistry-calculator by ryan m
<!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