@jjlmoya/utils-cooking 1.36.0 → 1.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/category/index.ts +6 -0
- package/src/entries.ts +7 -1
- package/src/index.ts +3 -0
- package/src/tests/brix-sorbet-density-calculator.test.ts +53 -0
- package/src/tests/i18n-titles.test.ts +2 -2
- package/src/tests/leavener-acid-neutralizer.test.ts +42 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/brix-sorbet-density-calculator/bibliography.astro +6 -0
- package/src/tool/brix-sorbet-density-calculator/bibliography.ts +10 -0
- package/src/tool/brix-sorbet-density-calculator/brix-sorbet-density-calculator.css +878 -0
- package/src/tool/brix-sorbet-density-calculator/component.astro +220 -0
- package/src/tool/brix-sorbet-density-calculator/entry.ts +26 -0
- package/src/tool/brix-sorbet-density-calculator/helpers.ts +102 -0
- package/src/tool/brix-sorbet-density-calculator/i18n/de.ts +295 -0
- package/src/tool/brix-sorbet-density-calculator/i18n/en.ts +295 -0
- package/src/tool/brix-sorbet-density-calculator/i18n/es.ts +295 -0
- package/src/tool/brix-sorbet-density-calculator/i18n/fr.ts +295 -0
- package/src/tool/brix-sorbet-density-calculator/i18n/id.ts +295 -0
- package/src/tool/brix-sorbet-density-calculator/i18n/it.ts +295 -0
- package/src/tool/brix-sorbet-density-calculator/i18n/ja.ts +295 -0
- package/src/tool/brix-sorbet-density-calculator/i18n/ko.ts +295 -0
- package/src/tool/brix-sorbet-density-calculator/i18n/nl.ts +295 -0
- package/src/tool/brix-sorbet-density-calculator/i18n/pl.ts +295 -0
- package/src/tool/brix-sorbet-density-calculator/i18n/pt.ts +295 -0
- package/src/tool/brix-sorbet-density-calculator/i18n/ru.ts +295 -0
- package/src/tool/brix-sorbet-density-calculator/i18n/sv.ts +295 -0
- package/src/tool/brix-sorbet-density-calculator/i18n/tr.ts +295 -0
- package/src/tool/brix-sorbet-density-calculator/i18n/zh.ts +295 -0
- package/src/tool/brix-sorbet-density-calculator/index.ts +11 -0
- package/src/tool/brix-sorbet-density-calculator/logic.ts +180 -0
- package/src/tool/brix-sorbet-density-calculator/script.ts +114 -0
- package/src/tool/brix-sorbet-density-calculator/seo.astro +15 -0
- package/src/tool/leavener-acid-neutralizer/bibliography.astro +6 -0
- package/src/tool/leavener-acid-neutralizer/bibliography.ts +10 -0
- package/src/tool/leavener-acid-neutralizer/component.astro +335 -0
- package/src/tool/leavener-acid-neutralizer/entry.ts +26 -0
- package/src/tool/leavener-acid-neutralizer/i18n/de.ts +279 -0
- package/src/tool/leavener-acid-neutralizer/i18n/en.ts +279 -0
- package/src/tool/leavener-acid-neutralizer/i18n/es.ts +275 -0
- package/src/tool/leavener-acid-neutralizer/i18n/fr.ts +279 -0
- package/src/tool/leavener-acid-neutralizer/i18n/id.ts +279 -0
- package/src/tool/leavener-acid-neutralizer/i18n/it.ts +275 -0
- package/src/tool/leavener-acid-neutralizer/i18n/ja.ts +279 -0
- package/src/tool/leavener-acid-neutralizer/i18n/ko.ts +279 -0
- package/src/tool/leavener-acid-neutralizer/i18n/nl.ts +279 -0
- package/src/tool/leavener-acid-neutralizer/i18n/pl.ts +279 -0
- package/src/tool/leavener-acid-neutralizer/i18n/pt.ts +279 -0
- package/src/tool/leavener-acid-neutralizer/i18n/ru.ts +279 -0
- package/src/tool/leavener-acid-neutralizer/i18n/sv.ts +279 -0
- package/src/tool/leavener-acid-neutralizer/i18n/tr.ts +279 -0
- package/src/tool/leavener-acid-neutralizer/i18n/zh.ts +279 -0
- package/src/tool/leavener-acid-neutralizer/index.ts +10 -0
- package/src/tool/leavener-acid-neutralizer/leavener-acid-neutralizer.css +424 -0
- package/src/tool/leavener-acid-neutralizer/logic.ts +57 -0
- package/src/tool/leavener-acid-neutralizer/seo.astro +15 -0
- package/src/tool/oil-smoke-point-tracker/bibliography.astro +6 -0
- package/src/tool/oil-smoke-point-tracker/bibliography.ts +10 -0
- package/src/tool/oil-smoke-point-tracker/component.astro +445 -0
- package/src/tool/oil-smoke-point-tracker/entry.ts +26 -0
- package/src/tool/oil-smoke-point-tracker/i18n/de.ts +285 -0
- package/src/tool/oil-smoke-point-tracker/i18n/en.ts +285 -0
- package/src/tool/oil-smoke-point-tracker/i18n/es.ts +285 -0
- package/src/tool/oil-smoke-point-tracker/i18n/fr.ts +285 -0
- package/src/tool/oil-smoke-point-tracker/i18n/id.ts +244 -0
- package/src/tool/oil-smoke-point-tracker/i18n/it.ts +244 -0
- package/src/tool/oil-smoke-point-tracker/i18n/ja.ts +244 -0
- package/src/tool/oil-smoke-point-tracker/i18n/ko.ts +244 -0
- package/src/tool/oil-smoke-point-tracker/i18n/nl.ts +244 -0
- package/src/tool/oil-smoke-point-tracker/i18n/pl.ts +244 -0
- package/src/tool/oil-smoke-point-tracker/i18n/pt.ts +244 -0
- package/src/tool/oil-smoke-point-tracker/i18n/ru.ts +244 -0
- package/src/tool/oil-smoke-point-tracker/i18n/sv.ts +244 -0
- package/src/tool/oil-smoke-point-tracker/i18n/tr.ts +244 -0
- package/src/tool/oil-smoke-point-tracker/i18n/zh.ts +244 -0
- package/src/tool/oil-smoke-point-tracker/index.ts +11 -0
- package/src/tool/oil-smoke-point-tracker/logic.ts +70 -0
- package/src/tool/oil-smoke-point-tracker/oil-smoke-point-tracker.css +937 -0
- package/src/tool/oil-smoke-point-tracker/seo.astro +15 -0
- package/src/tools.ts +6 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { BrixState } from './helpers';
|
|
2
|
+
import { getUnitLabel, formatWeight, formatWeightVal, cToF, getTempStatusDash } from './helpers';
|
|
3
|
+
|
|
4
|
+
export type { BrixState, SorbetInputs, SorbetResult } from './helpers';
|
|
5
|
+
export { SorbetLogic } from './helpers';
|
|
6
|
+
|
|
7
|
+
const STORAGE_KEY = 'bsdc-v1';
|
|
8
|
+
|
|
9
|
+
const LOAD_FIELDS = [
|
|
10
|
+
{ key: 'fw', elId: 'bsdc-fruit-weight' },
|
|
11
|
+
{ key: 'fb', elId: 'bsdc-fruit-brix' },
|
|
12
|
+
{ key: 'sw', elId: 'bsdc-sugar-weight' },
|
|
13
|
+
{ key: 'dw', elId: 'bsdc-dextrose-weight' },
|
|
14
|
+
{ key: 'ww', elId: 'bsdc-water-weight' },
|
|
15
|
+
{ key: 'tb', elId: 'bsdc-target-brix' },
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
function updateDisplayValues(state: BrixState): void {
|
|
19
|
+
const { elements: el, activeUnit } = state;
|
|
20
|
+
el.fruitWeightVal.textContent = String(formatWeightVal(parseFloat(el.fruitWeight.value) || 0, state));
|
|
21
|
+
el.fruitBrixVal.textContent = el.fruitBrix.value;
|
|
22
|
+
el.sugarWeightVal.textContent = String(formatWeightVal(parseFloat(el.sugarWeight.value) || 0, state));
|
|
23
|
+
el.dextroseWeightVal.textContent = String(formatWeightVal(parseFloat(el.dextroseWeight.value) || 0, state));
|
|
24
|
+
el.waterWeightVal.textContent = String(formatWeightVal(parseFloat(el.waterWeight.value) || 0, state));
|
|
25
|
+
el.targetBrixVal.textContent = el.targetBrix.value;
|
|
26
|
+
|
|
27
|
+
const labelText = getUnitLabel(state);
|
|
28
|
+
document.querySelectorAll('.bsdc-weight-unit').forEach((el2) => { el2.textContent = labelText; });
|
|
29
|
+
|
|
30
|
+
const labelsEl = document.getElementById('bsdc-temp-labels');
|
|
31
|
+
if (labelsEl) {
|
|
32
|
+
labelsEl.innerHTML = activeUnit === 'imperial'
|
|
33
|
+
? '<span>-13°F</span><span>-4°F</span><span>5°F</span><span>14°F</span><span>23°F</span>'
|
|
34
|
+
: '<span>-25°C</span><span>-20°C</span><span>-15°C</span><span>-10°C</span><span>-5°C</span>';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function updatePresetDropdownUI(state: BrixState): void {
|
|
39
|
+
const { selectOptions, selectedIcon, selectedName } = state.elements;
|
|
40
|
+
selectOptions.forEach((opt) => {
|
|
41
|
+
const isSelected = opt.getAttribute('data-value') === state.activePreset;
|
|
42
|
+
opt.classList.toggle('selected', isSelected);
|
|
43
|
+
if (!isSelected) return;
|
|
44
|
+
selectedIcon.innerHTML = (opt.querySelector('.bsdc-preset-icon') as HTMLElement).innerHTML;
|
|
45
|
+
const nameSpan = opt.querySelector('span:not(.bsdc-preset-icon)');
|
|
46
|
+
if (nameSpan) selectedName.textContent = nameSpan.textContent;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function updateBadge(brix: number, state: BrixState): void {
|
|
51
|
+
const card = document.querySelector('.bsdc-card');
|
|
52
|
+
if (card) card.classList.remove('status-hard', 'status-soft', 'status-optimal');
|
|
53
|
+
const { statusBadge } = state.elements;
|
|
54
|
+
const s = state.ui;
|
|
55
|
+
if (brix < 25) {
|
|
56
|
+
statusBadge.textContent = s.statusHard;
|
|
57
|
+
statusBadge.className = 'bsdc-badge hard';
|
|
58
|
+
if (card) card.classList.add('status-hard');
|
|
59
|
+
} else if (brix > 30) {
|
|
60
|
+
statusBadge.textContent = s.statusSoft;
|
|
61
|
+
statusBadge.className = 'bsdc-badge soft';
|
|
62
|
+
if (card) card.classList.add('status-soft');
|
|
63
|
+
} else {
|
|
64
|
+
statusBadge.textContent = s.statusOptimal;
|
|
65
|
+
statusBadge.className = 'bsdc-badge optimal';
|
|
66
|
+
if (card) card.classList.add('status-optimal');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function updateTempMarker(tMin: number, tMax: number): void {
|
|
71
|
+
const marker = document.getElementById('bsdc-temp-marker');
|
|
72
|
+
if (!marker) return;
|
|
73
|
+
marker.classList.remove('out-of-range-cold', 'out-of-range-warm');
|
|
74
|
+
if (tMin < -25) {
|
|
75
|
+
marker.style.left = '0%';
|
|
76
|
+
marker.style.width = '12%';
|
|
77
|
+
marker.classList.add('out-of-range-cold');
|
|
78
|
+
} else if (tMax > -5) {
|
|
79
|
+
marker.style.left = '88%';
|
|
80
|
+
marker.style.width = '12%';
|
|
81
|
+
marker.classList.add('out-of-range-warm');
|
|
82
|
+
} else {
|
|
83
|
+
const pMin = Math.max(0, Math.min(100, ((tMin + 25) / 20) * 100));
|
|
84
|
+
const pMax = Math.max(0, Math.min(100, ((tMax + 25) / 20) * 100));
|
|
85
|
+
marker.style.left = pMin.toFixed(1) + '%';
|
|
86
|
+
marker.style.width = Math.max(8, pMax - pMin).toFixed(1) + '%';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function updateTempGauge(totalPAC: number, state: BrixState): void {
|
|
91
|
+
const tIdeal = -(totalPAC / 18);
|
|
92
|
+
const tMin = tIdeal - 2.5;
|
|
93
|
+
const tMax = tIdeal + 2.5;
|
|
94
|
+
updateTempMarker(tMin, tMax);
|
|
95
|
+
|
|
96
|
+
const rangeVal = document.getElementById('bsdc-temp-range-val');
|
|
97
|
+
if (rangeVal) {
|
|
98
|
+
if (state.activeUnit === 'imperial') {
|
|
99
|
+
const fIdeal = cToF(tIdeal);
|
|
100
|
+
rangeVal.textContent = (fIdeal - 4.5).toFixed(0) + '°F to ' + (fIdeal + 4.5).toFixed(0) + '°F';
|
|
101
|
+
} else {
|
|
102
|
+
rangeVal.textContent = tMin.toFixed(0) + '°C to ' + tMax.toFixed(0) + '°C';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const tempStatus = document.getElementById('bsdc-temp-status');
|
|
107
|
+
if (tempStatus) {
|
|
108
|
+
const st = getTempStatusDash(tIdeal);
|
|
109
|
+
tempStatus.textContent = st.text;
|
|
110
|
+
tempStatus.className = st.className;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function calculate(state: BrixState): void {
|
|
115
|
+
const { elements: el } = state;
|
|
116
|
+
const fw = parseFloat(el.fruitWeight.value) || 0;
|
|
117
|
+
const fb = parseFloat(el.fruitBrix.value) || 0;
|
|
118
|
+
const sw = parseFloat(el.sugarWeight.value) || 0;
|
|
119
|
+
const dw = parseFloat(el.dextroseWeight.value) || 0;
|
|
120
|
+
const ww = parseFloat(el.waterWeight.value) || 0;
|
|
121
|
+
const tb = parseFloat(el.targetBrix.value) || 28;
|
|
122
|
+
const fruitSugar = fw * (fb / 100);
|
|
123
|
+
const totalWeight = fw + ww + sw + dw;
|
|
124
|
+
const brix = totalWeight > 0 ? ((fruitSugar + sw + dw) / totalWeight) * 100 : 0;
|
|
125
|
+
const totalPAC = fruitSugar * 1.9 + sw + dw * 1.9;
|
|
126
|
+
el.totalBrixDisp.textContent = brix.toFixed(1) + '%';
|
|
127
|
+
el.totalWeightDisp.textContent = formatWeight(totalWeight, state);
|
|
128
|
+
el.totalPacDisp.textContent = totalPAC.toFixed(1);
|
|
129
|
+
el.lens.style.transform = `translateY(-${Math.min(50, (brix / 50) * 50) * 0.5}%)`;
|
|
130
|
+
updateBadge(brix, state);
|
|
131
|
+
updateTempGauge(totalPAC, state);
|
|
132
|
+
const recSugar = Math.max(0, fw * (2 * (tb / 100) - fb / 100));
|
|
133
|
+
const recWater = Math.max(0, fw - recSugar);
|
|
134
|
+
el.recSugarDisp.textContent = formatWeight(recSugar, state);
|
|
135
|
+
el.recWaterDisp.textContent = formatWeight(recWater, state);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function save(state: BrixState): void {
|
|
139
|
+
try {
|
|
140
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
|
141
|
+
fw: state.elements.fruitWeight.value,
|
|
142
|
+
fb: state.elements.fruitBrix.value,
|
|
143
|
+
sw: state.elements.sugarWeight.value,
|
|
144
|
+
dw: state.elements.dextroseWeight.value,
|
|
145
|
+
ww: state.elements.waterWeight.value,
|
|
146
|
+
tb: state.elements.targetBrix.value,
|
|
147
|
+
pr: state.activePreset,
|
|
148
|
+
un: state.activeUnit,
|
|
149
|
+
}));
|
|
150
|
+
} catch {}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function load(state: BrixState): void {
|
|
154
|
+
try {
|
|
155
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
156
|
+
if (!raw) return;
|
|
157
|
+
const data = JSON.parse(raw) as Record<string, string>;
|
|
158
|
+
if (data.un) state.activeUnit = data.un;
|
|
159
|
+
if (data.pr) state.activePreset = data.pr;
|
|
160
|
+
LOAD_FIELDS.forEach(({ key, elId }) => {
|
|
161
|
+
if (data[key] !== undefined) {
|
|
162
|
+
const el = document.getElementById(elId) as HTMLInputElement;
|
|
163
|
+
if (el) el.value = data[key];
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
} catch {}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function refresh(state: BrixState): void {
|
|
170
|
+
updateDisplayValues(state);
|
|
171
|
+
calculate(state);
|
|
172
|
+
save(state);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function toggleUnit(unit: string, state: BrixState): void {
|
|
176
|
+
state.activeUnit = unit;
|
|
177
|
+
state.elements.unitMetricBtn.classList.toggle('active', unit === 'metric');
|
|
178
|
+
state.elements.unitImperialBtn.classList.toggle('active', unit === 'imperial');
|
|
179
|
+
refresh(state);
|
|
180
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { BrixState } from './logic';
|
|
2
|
+
import { toggleUnit, load, updatePresetDropdownUI, refresh } from './logic';
|
|
3
|
+
|
|
4
|
+
function createElements() {
|
|
5
|
+
const g = (id: string) => document.getElementById(id);
|
|
6
|
+
return {
|
|
7
|
+
selectTrigger: g('bsdc-select-trigger')!,
|
|
8
|
+
selectDropdown: g('bsdc-select-dropdown')!,
|
|
9
|
+
selectOptions: document.querySelectorAll('.bsdc-select-option'),
|
|
10
|
+
selectedIcon: g('bsdc-selected-icon')!,
|
|
11
|
+
selectedName: g('bsdc-selected-name')!,
|
|
12
|
+
fruitWeight: g('bsdc-fruit-weight') as HTMLInputElement,
|
|
13
|
+
fruitBrix: g('bsdc-fruit-brix') as HTMLInputElement,
|
|
14
|
+
sugarWeight: g('bsdc-sugar-weight') as HTMLInputElement,
|
|
15
|
+
dextroseWeight: g('bsdc-dextrose-weight') as HTMLInputElement,
|
|
16
|
+
waterWeight: g('bsdc-water-weight') as HTMLInputElement,
|
|
17
|
+
targetBrix: g('bsdc-target-brix') as HTMLInputElement,
|
|
18
|
+
fruitWeightVal: g('bsdc-fruit-weight-val')!,
|
|
19
|
+
fruitBrixVal: g('bsdc-fruit-brix-val')!,
|
|
20
|
+
sugarWeightVal: g('bsdc-sugar-weight-val')!,
|
|
21
|
+
dextroseWeightVal: g('bsdc-dextrose-weight-val')!,
|
|
22
|
+
waterWeightVal: g('bsdc-water-weight-val')!,
|
|
23
|
+
targetBrixVal: g('bsdc-target-brix-val')!,
|
|
24
|
+
totalBrixDisp: g('bsdc-total-brix')!,
|
|
25
|
+
totalWeightDisp: g('bsdc-total-weight')!,
|
|
26
|
+
totalPacDisp: g('bsdc-total-pac')!,
|
|
27
|
+
statusBadge: g('bsdc-status-badge')!,
|
|
28
|
+
lens: g('bsdc-lens')!,
|
|
29
|
+
recSugarDisp: g('bsdc-rec-sugar')!,
|
|
30
|
+
recWaterDisp: g('bsdc-rec-water')!,
|
|
31
|
+
unitMetricBtn: g('bsdc-unit-metric')!, unitImperialBtn: g('bsdc-unit-imperial')!,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function bindSelectDropdown(el: BrixState['elements']): void {
|
|
36
|
+
el.selectTrigger.addEventListener('click', (e) => {
|
|
37
|
+
e.stopPropagation();
|
|
38
|
+
el.selectTrigger.classList.toggle('open');
|
|
39
|
+
el.selectDropdown.classList.toggle('open');
|
|
40
|
+
});
|
|
41
|
+
document.addEventListener('click', (e) => {
|
|
42
|
+
if (!el.selectTrigger.contains(e.target as Node) && !el.selectDropdown.contains(e.target as Node)) {
|
|
43
|
+
el.selectTrigger.classList.remove('open');
|
|
44
|
+
el.selectDropdown.classList.remove('open');
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function bindSelectOptions(state: BrixState): void {
|
|
50
|
+
const { elements: el } = state;
|
|
51
|
+
el.selectOptions.forEach((opt) => {
|
|
52
|
+
opt.addEventListener('click', () => {
|
|
53
|
+
const val = opt.getAttribute('data-value')!;
|
|
54
|
+
state.activePreset = val;
|
|
55
|
+
if (val !== 'custom') el.fruitBrix.value = val;
|
|
56
|
+
el.selectTrigger.classList.remove('open');
|
|
57
|
+
el.selectDropdown.classList.remove('open');
|
|
58
|
+
updatePresetDropdownUI(state);
|
|
59
|
+
refresh(state);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function bindAdjusters(state: BrixState): void {
|
|
65
|
+
document.querySelectorAll('.bsdc-adjuster-btn').forEach((btn) => {
|
|
66
|
+
btn.addEventListener('click', () => {
|
|
67
|
+
const targetId = btn.getAttribute('data-target')!;
|
|
68
|
+
const slider = document.getElementById(targetId) as HTMLInputElement;
|
|
69
|
+
if (!slider) return;
|
|
70
|
+
const step = parseFloat(slider.getAttribute('step') || '1');
|
|
71
|
+
const cur = parseFloat(slider.value) || 0;
|
|
72
|
+
const dir = btn.getAttribute('data-dir');
|
|
73
|
+
slider.value = String(Math.max(parseFloat(slider.min), Math.min(parseFloat(slider.max), cur + (dir === 'up' ? step : -step))));
|
|
74
|
+
if (targetId === 'bsdc-fruit-brix') {
|
|
75
|
+
state.activePreset = 'custom';
|
|
76
|
+
updatePresetDropdownUI(state);
|
|
77
|
+
}
|
|
78
|
+
refresh(state);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function bindSliderInputs(state: BrixState): void {
|
|
84
|
+
const { elements: el } = state;
|
|
85
|
+
el.fruitWeight.addEventListener('input', () => refresh(state));
|
|
86
|
+
el.fruitBrix.addEventListener('input', () => {
|
|
87
|
+
state.activePreset = 'custom';
|
|
88
|
+
updatePresetDropdownUI(state);
|
|
89
|
+
refresh(state);
|
|
90
|
+
});
|
|
91
|
+
el.sugarWeight.addEventListener('input', () => refresh(state));
|
|
92
|
+
el.dextroseWeight.addEventListener('input', () => refresh(state));
|
|
93
|
+
el.waterWeight.addEventListener('input', () => refresh(state));
|
|
94
|
+
el.targetBrix.addEventListener('input', () => refresh(state));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function bindEvents(state: BrixState): void {
|
|
98
|
+
const { elements: el } = state;
|
|
99
|
+
el.unitMetricBtn.addEventListener('click', () => toggleUnit('metric', state));
|
|
100
|
+
el.unitImperialBtn.addEventListener('click', () => toggleUnit('imperial', state));
|
|
101
|
+
bindSelectDropdown(el);
|
|
102
|
+
bindSelectOptions(state);
|
|
103
|
+
bindAdjusters(state);
|
|
104
|
+
bindSliderInputs(state);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function initBrixCalculator(ui: Record<string, string>): void {
|
|
108
|
+
const elements = createElements();
|
|
109
|
+
const state: BrixState = { activeUnit: 'metric', activePreset: '14', ui, elements };
|
|
110
|
+
bindEvents(state);
|
|
111
|
+
load(state);
|
|
112
|
+
updatePresetDropdownUI(state);
|
|
113
|
+
toggleUnit(state.activeUnit, state);
|
|
114
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { brixSorbetDensity } from './entry';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props;
|
|
11
|
+
const content = await brixSorbetDensity.i18n[locale]?.();
|
|
12
|
+
if (!content) return null;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const bibliography = [
|
|
2
|
+
{
|
|
3
|
+
name: 'Changing chemical leavening to improve the structural, textural and sensory properties of functional cakes with blackcurrant pomace',
|
|
4
|
+
url: 'https://www.sciencedirect.com/science/article/abs/pii/S0023643820303674',
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
name: 'Understanding Baking Soda vs. Baking Powder',
|
|
8
|
+
url: 'https://www.utas.edu.au/about/news-and-stories/articles/2025/whats-the-difference-between-baking-powder-and-baking-soda-its-subtle,-but-significant',
|
|
9
|
+
},
|
|
10
|
+
];
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
---
|
|
2
|
+
import './leavener-acid-neutralizer.css';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
ui: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { ui } = Astro.props;
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<div class="leavener-container">
|
|
12
|
+
<div class="leavener-card">
|
|
13
|
+
<div class="recipe-setup-card">
|
|
14
|
+
<div class="top-row">
|
|
15
|
+
<div class="luxury-title-group">
|
|
16
|
+
<span class="luxury-badge">{ui.stoichiometryBadge}</span>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="unit-selector">
|
|
19
|
+
<button id="unit-metric-btn" class="unit-btn active">{ui.metricUnit}</button>
|
|
20
|
+
<button id="unit-imperial-btn" class="unit-btn">{ui.imperialUnit}</button>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="flour-slider-group">
|
|
25
|
+
<div class="flour-header">
|
|
26
|
+
<span class="flour-title-label">{ui.flourLabel}</span>
|
|
27
|
+
<span id="flour-val-display" class="flour-val-num">150g</span>
|
|
28
|
+
</div>
|
|
29
|
+
<input type="range" id="flour-slider" min="50" max="1000" step="10" value="150" class="premium-range-slider" />
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div class="main-workspace-layout">
|
|
34
|
+
<div class="control-panel-card">
|
|
35
|
+
<h4 class="panel-subtitle">Select Acidic Ingredients</h4>
|
|
36
|
+
<div class="ingredients-pills">
|
|
37
|
+
<button class="pill-btn" data-type="buttermilk" data-name={ui.acid_buttermilk}>{ui.acid_buttermilk}</button>
|
|
38
|
+
<button class="pill-btn active" data-type="yogurt" data-name={ui.acid_yogurt}>{ui.acid_yogurt}</button>
|
|
39
|
+
<button class="pill-btn" data-type="sour_cream" data-name={ui.acid_sour_cream}>{ui.acid_sour_cream}</button>
|
|
40
|
+
<button class="pill-btn" data-type="honey" data-name={ui.acid_honey}>{ui.acid_honey}</button>
|
|
41
|
+
<button class="pill-btn" data-type="molasses" data-name={ui.acid_molasses}>{ui.acid_molasses}</button>
|
|
42
|
+
<button class="pill-btn" data-type="cocoa" data-name={ui.acid_cocoa}>{ui.acid_cocoa}</button>
|
|
43
|
+
<button class="pill-btn" data-type="lemon_juice" data-name={ui.acid_lemon_juice}>{ui.acid_lemon_juice}</button>
|
|
44
|
+
<button class="pill-btn" data-type="vinegar" data-name={ui.acid_vinegar}>{ui.acid_vinegar}</button>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div id="active-sliders-container" class="active-sliders-list"></div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div class="analysis-panel-card">
|
|
51
|
+
<h4 class="panel-subtitle">{ui.scaleBalanceTitle}</h4>
|
|
52
|
+
|
|
53
|
+
<div class="linear-ph-scale-card">
|
|
54
|
+
<div class="ph-track">
|
|
55
|
+
<div class="ph-zone acid-zone"></div>
|
|
56
|
+
<div class="ph-zone neutral-zone"></div>
|
|
57
|
+
<div class="ph-zone alkaline-zone"></div>
|
|
58
|
+
<div id="ph-pointer-orb" class="ph-pointer-orb"></div>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="ph-labels">
|
|
61
|
+
<span>{ui.statusAcidic}</span>
|
|
62
|
+
<span class="neutral-label">{ui.statusBalanced}</span>
|
|
63
|
+
<span>{ui.statusAlkaline}</span>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div class="simulation-adjuster">
|
|
68
|
+
<div class="slider-header-row">
|
|
69
|
+
<span class="adjuster-label">{ui.simulateSodaLabel}</span>
|
|
70
|
+
<div class="adjuster-controls">
|
|
71
|
+
<span id="simulator-val" class="adjuster-val">3.00g</span>
|
|
72
|
+
<button id="auto-balance-btn" class="btn-auto-balance">{ui.autoBalanceBtn}</button>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
<input type="range" id="simulator-slider" min="0" max="10" step="0.05" value="3.0" class="premium-range-slider" />
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<div class="perfect-recipe-card">
|
|
79
|
+
<h4 class="recipe-title">{ui.resultsTitle}</h4>
|
|
80
|
+
|
|
81
|
+
<div class="recipe-stat-row">
|
|
82
|
+
<div class="recipe-stat-info">
|
|
83
|
+
<span class="stat-main-label">{ui.bakingSodaNeeded}</span>
|
|
84
|
+
<span class="stat-sub-label">Required to neutralize all acid</span>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="recipe-stat-numbers">
|
|
87
|
+
<span id="formula-soda-g" class="num-large">0.00g</span>
|
|
88
|
+
<span id="formula-soda-tsp" class="num-small">0.00 tsp</span>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div class="recipe-stat-row">
|
|
93
|
+
<div class="recipe-stat-info">
|
|
94
|
+
<span class="stat-main-label">{ui.boosterBakingPowder}</span>
|
|
95
|
+
<span class="stat-sub-label">Additional booster for leavening</span>
|
|
96
|
+
</div>
|
|
97
|
+
<div class="recipe-stat-numbers">
|
|
98
|
+
<span id="formula-booster-g" class="num-large">0.00g</span>
|
|
99
|
+
<span id="formula-booster-tsp" class="num-small">0.00 tsp</span>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<script is:inline define:vars={{ ui }}>
|
|
109
|
+
const flourSlider = document.getElementById('flour-slider');
|
|
110
|
+
const flourValDisplay = document.getElementById('flour-val-display');
|
|
111
|
+
const unitMetricBtn = document.getElementById('unit-metric-btn');
|
|
112
|
+
const unitImperialBtn = document.getElementById('unit-imperial-btn');
|
|
113
|
+
const pillBtns = document.querySelectorAll('.pill-btn');
|
|
114
|
+
const activeSlidersContainer = document.getElementById('active-sliders-container');
|
|
115
|
+
|
|
116
|
+
const phPointerOrb = document.getElementById('ph-pointer-orb');
|
|
117
|
+
const simulatorSlider = document.getElementById('simulator-slider');
|
|
118
|
+
const simulatorVal = document.getElementById('simulator-val');
|
|
119
|
+
const autoBalanceBtn = document.getElementById('auto-balance-btn');
|
|
120
|
+
|
|
121
|
+
const formulaSodaG = document.getElementById('formula-soda-g');
|
|
122
|
+
const formulaSodaTsp = document.getElementById('formula-soda-tsp');
|
|
123
|
+
const formulaBoosterG = document.getElementById('formula-booster-g');
|
|
124
|
+
const formulaBoosterTsp = document.getElementById('formula-booster-tsp');
|
|
125
|
+
|
|
126
|
+
let currentUnit = 'metric';
|
|
127
|
+
let targetRecommendedSoda = 3.0;
|
|
128
|
+
|
|
129
|
+
const activeIngredients = {
|
|
130
|
+
yogurt: 240
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const ACID_RATIOS = {
|
|
134
|
+
buttermilk: 1.5 / 120,
|
|
135
|
+
yogurt: 1.5 / 120,
|
|
136
|
+
sour_cream: 1.5 / 120,
|
|
137
|
+
honey: 3 / 340,
|
|
138
|
+
molasses: 3 / 328,
|
|
139
|
+
cocoa: 3 / 80,
|
|
140
|
+
lemon_juice: 1.5 / 15,
|
|
141
|
+
vinegar: 1.5 / 15
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const MAX_LIMITS = {
|
|
145
|
+
buttermilk: 500,
|
|
146
|
+
yogurt: 500,
|
|
147
|
+
sour_cream: 500,
|
|
148
|
+
honey: 400,
|
|
149
|
+
molasses: 400,
|
|
150
|
+
cocoa: 100,
|
|
151
|
+
lemon_juice: 100,
|
|
152
|
+
vinegar: 100
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
unitMetricBtn.addEventListener('click', () => {
|
|
156
|
+
if (currentUnit !== 'metric') {
|
|
157
|
+
const oldFlour = parseFloat(flourSlider.value) || 0;
|
|
158
|
+
flourSlider.min = "50";
|
|
159
|
+
flourSlider.max = "1000";
|
|
160
|
+
flourSlider.step = "10";
|
|
161
|
+
flourSlider.value = (oldFlour * 28.3495).toFixed(0);
|
|
162
|
+
|
|
163
|
+
for (const key in activeIngredients) {
|
|
164
|
+
activeIngredients[key] = parseFloat((activeIngredients[key] * 28.3495).toFixed(0));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const oldSlider = parseFloat(simulatorSlider.value);
|
|
168
|
+
simulatorSlider.value = (oldSlider * 28.3495).toFixed(2);
|
|
169
|
+
|
|
170
|
+
currentUnit = 'metric';
|
|
171
|
+
unitMetricBtn.classList.add('active');
|
|
172
|
+
unitImperialBtn.classList.remove('active');
|
|
173
|
+
renderSliders();
|
|
174
|
+
update();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
unitImperialBtn.addEventListener('click', () => {
|
|
179
|
+
if (currentUnit !== 'imperial') {
|
|
180
|
+
const oldFlour = parseFloat(flourSlider.value) || 0;
|
|
181
|
+
flourSlider.min = "2";
|
|
182
|
+
flourSlider.max = "36";
|
|
183
|
+
flourSlider.step = "0.5";
|
|
184
|
+
flourSlider.value = (oldFlour / 28.3495).toFixed(1);
|
|
185
|
+
|
|
186
|
+
for (const key in activeIngredients) {
|
|
187
|
+
activeIngredients[key] = parseFloat((activeIngredients[key] / 28.3495).toFixed(1));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const oldSlider = parseFloat(simulatorSlider.value);
|
|
191
|
+
simulatorSlider.value = (oldSlider / 28.3495).toFixed(3);
|
|
192
|
+
|
|
193
|
+
currentUnit = 'imperial';
|
|
194
|
+
unitImperialBtn.classList.add('active');
|
|
195
|
+
unitMetricBtn.classList.remove('active');
|
|
196
|
+
renderSliders();
|
|
197
|
+
update();
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
pillBtns.forEach(btn => {
|
|
202
|
+
btn.addEventListener('click', () => {
|
|
203
|
+
const type = btn.getAttribute('data-type');
|
|
204
|
+
if (activeIngredients[type] !== undefined) {
|
|
205
|
+
delete activeIngredients[type];
|
|
206
|
+
btn.classList.remove('active');
|
|
207
|
+
} else {
|
|
208
|
+
activeIngredients[type] = currentUnit === 'metric' ? 100 : 3.5;
|
|
209
|
+
btn.classList.add('active');
|
|
210
|
+
}
|
|
211
|
+
renderSliders();
|
|
212
|
+
update();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
function createSliderElement(key, isMetric, unitText) {
|
|
217
|
+
const wrapper = document.createElement('div');
|
|
218
|
+
wrapper.className = 'slider-wrapper-card';
|
|
219
|
+
const maxVal = isMetric ? MAX_LIMITS[key] : parseFloat((MAX_LIMITS[key] / 28.3495).toFixed(1));
|
|
220
|
+
const stepVal = isMetric ? 5 : 0.1;
|
|
221
|
+
wrapper.innerHTML = `<div class="slider-header-row"><span class="slider-label-name">${ui[`acid_${key}`] || key}</span><span class="slider-value-num">${activeIngredients[key]}${unitText}</span></div><input type="range" class="premium-range-slider" min="0" max="${maxVal}" stroke-linecap="round" step="${stepVal}" value="${activeIngredients[key]}" />`;
|
|
222
|
+
wrapper.querySelector('input').addEventListener('input', (e) => {
|
|
223
|
+
activeIngredients[key] = parseFloat(e.target.value) || 0;
|
|
224
|
+
wrapper.querySelector('.slider-value-num').textContent = `${activeIngredients[key]}${unitText}`;
|
|
225
|
+
update();
|
|
226
|
+
});
|
|
227
|
+
return wrapper;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function renderSliders() {
|
|
231
|
+
activeSlidersContainer.innerHTML = '';
|
|
232
|
+
const isMetric = currentUnit === 'metric';
|
|
233
|
+
const unitText = isMetric ? 'g' : 'oz';
|
|
234
|
+
|
|
235
|
+
for (const key in activeIngredients) {
|
|
236
|
+
const element = createSliderElement(key, isMetric, unitText);
|
|
237
|
+
activeSlidersContainer.appendChild(element);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
autoBalanceBtn.addEventListener('click', () => {
|
|
242
|
+
const isMetric = currentUnit === 'metric';
|
|
243
|
+
if (isMetric) {
|
|
244
|
+
simulatorSlider.value = targetRecommendedSoda.toFixed(2);
|
|
245
|
+
} else {
|
|
246
|
+
simulatorSlider.value = (targetRecommendedSoda / 28.3495).toFixed(3);
|
|
247
|
+
}
|
|
248
|
+
update();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
function getNeutralizedSoda(multiplier) {
|
|
252
|
+
let neutralized = 0;
|
|
253
|
+
for (const key in activeIngredients) {
|
|
254
|
+
const weightGrams = activeIngredients[key] * multiplier;
|
|
255
|
+
const ratio = ACID_RATIOS[key] || 0;
|
|
256
|
+
neutralized += weightGrams * ratio;
|
|
257
|
+
}
|
|
258
|
+
return neutralized;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function updatePointer(diff, neutralizedSoda, simulatedSoda) {
|
|
262
|
+
let leftPercent = 50;
|
|
263
|
+
if (neutralizedSoda > 0) {
|
|
264
|
+
const ratio = simulatedSoda / neutralizedSoda;
|
|
265
|
+
if (ratio > 1) {
|
|
266
|
+
leftPercent = 50 + Math.min(45, (ratio - 1) * 35);
|
|
267
|
+
} else if (ratio < 1) {
|
|
268
|
+
leftPercent = 50 - Math.min(45, (1 - ratio) * 50);
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
leftPercent = simulatedSoda > 0 ? 95 : 50;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
phPointerOrb.style.left = `${leftPercent}%`;
|
|
275
|
+
|
|
276
|
+
if (Math.abs(diff) <= 0.15) {
|
|
277
|
+
phPointerOrb.style.backgroundColor = '#10b981';
|
|
278
|
+
phPointerOrb.style.boxShadow = '0 0 20px #10b981';
|
|
279
|
+
} else if (diff > 0.15) {
|
|
280
|
+
phPointerOrb.style.backgroundColor = '#3b82f6';
|
|
281
|
+
phPointerOrb.style.boxShadow = '0 0 20px #3b82f6';
|
|
282
|
+
} else {
|
|
283
|
+
phPointerOrb.style.backgroundColor = '#ec4899';
|
|
284
|
+
phPointerOrb.style.boxShadow = '0 0 20px #ec4899';
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function updateOutputs(data) {
|
|
289
|
+
const isMetric = data.isMetric;
|
|
290
|
+
const neutralizedSoda = data.neutralizedSoda;
|
|
291
|
+
const boosterPowder = data.boosterPowder;
|
|
292
|
+
const simulatedSoda = data.simulatedSoda;
|
|
293
|
+
const sodaTsp = (neutralizedSoda / 6);
|
|
294
|
+
const boosterTsp = (boosterPowder / 4.8);
|
|
295
|
+
|
|
296
|
+
if (isMetric) {
|
|
297
|
+
formulaSodaG.textContent = `${neutralizedSoda.toFixed(2)}${ui.gramsUnit}`;
|
|
298
|
+
formulaBoosterG.textContent = `${boosterPowder.toFixed(2)}${ui.gramsUnit}`;
|
|
299
|
+
simulatorVal.textContent = `${simulatedSoda.toFixed(2)}${ui.gramsUnit}`;
|
|
300
|
+
formulaSodaTsp.style.display = 'none';
|
|
301
|
+
formulaBoosterTsp.style.display = 'none';
|
|
302
|
+
sodaSliderLabel.textContent = `${ui.sodaAddedLabel} (${ui.gramsUnit})`;
|
|
303
|
+
} else {
|
|
304
|
+
formulaSodaG.textContent = `${(neutralizedSoda / 28.3495).toFixed(3)}${ui.ouncesUnit}`;
|
|
305
|
+
formulaBoosterG.textContent = `${(boosterPowder / 28.3495).toFixed(3)}${ui.ouncesUnit}`;
|
|
306
|
+
simulatorVal.textContent = `${(simulatedSoda / 28.3495).toFixed(3)}${ui.ouncesUnit}`;
|
|
307
|
+
formulaSodaTsp.style.display = 'block';
|
|
308
|
+
formulaBoosterTsp.style.display = 'block';
|
|
309
|
+
formulaSodaTsp.textContent = `${sodaTsp.toFixed(2)} ${ui.teaspoonsUnit}`;
|
|
310
|
+
formulaBoosterTsp.textContent = `${boosterTsp.toFixed(2)} ${ui.teaspoonsUnit}`;
|
|
311
|
+
sodaSliderLabel.textContent = `${ui.sodaAddedLabel} (${ui.ouncesUnit})`;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function update() {
|
|
316
|
+
const isMetric = currentUnit === 'metric';
|
|
317
|
+
const multiplier = isMetric ? 1 : 28.3495;
|
|
318
|
+
const flourVal = parseFloat(flourSlider.value) || 0;
|
|
319
|
+
flourValDisplay.textContent = `${flourVal}${isMetric ? 'g' : 'oz'}`;
|
|
320
|
+
const neutralizedSoda = getNeutralizedSoda(multiplier);
|
|
321
|
+
targetRecommendedSoda = neutralizedSoda;
|
|
322
|
+
const requiredPowder = flourVal * multiplier * 0.04;
|
|
323
|
+
const boosterPowder = Math.max(0, requiredPowder - (neutralizedSoda * 4));
|
|
324
|
+
simulatorSlider.max = Math.max(10, Math.ceil((neutralizedSoda / multiplier) * 2)).toString();
|
|
325
|
+
const simulatedSoda = parseFloat(simulatorSlider.value) * multiplier;
|
|
326
|
+
updateOutputs({ isMetric, neutralizedSoda, requiredPowder, boosterPowder, simulatedSoda });
|
|
327
|
+
updatePointer(simulatedSoda - neutralizedSoda, neutralizedSoda, simulatedSoda);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
flourSlider.addEventListener('input', update);
|
|
331
|
+
simulatorSlider.addEventListener('input', update);
|
|
332
|
+
|
|
333
|
+
renderSliders();
|
|
334
|
+
update();
|
|
335
|
+
</script>
|