volpe/js/measurements.js

391 lines
No EOL
13 KiB
JavaScript

/**
* Client-side measurement toggle + recipe scaling.
* Default: metric units, collapsed times, 1x scale.
*
* Each measurement span has:
* data-default / data-alt — pre-computed metric/imperial text (1x)
* data-scalable — JSON with base values in g/ml for scaling
* data-measurement-type — weight|volume|temperature|dimension|time
*/
(function () {
'use strict';
// ─── State ──────────────────────────────────────────────
var showingAlt = {};
var scale = 1;
var measureByVolume = false;
var TYPE_LABELS = {
temperature: { toAlt: '°F', toDefault: '°C' },
weight: { toAlt: 'lb', toDefault: 'kg' },
volume: { toAlt: 'cups', toDefault: 'ml' },
dimension: { toAlt: 'inch', toDefault: 'cm' },
time: { toAlt: 'hr+min', toDefault: 'min' },
};
// ─── Formatting (mirrors plugin logic) ──────────────────
var NO_SPACE = { 'g':1, 'kg':1, 'ml':1, '°C':1, '°F':1, 'cm':1, 'mm':1 };
function formatNumber(n) {
if (Number.isInteger(n) || Math.abs(n - Math.round(n)) < 0.001) {
return Math.round(n).toString();
}
var decimals = Math.abs(n) >= 10 ? 1 : 2;
return n.toFixed(decimals).replace(/\.?0+$/, '');
}
function unitLabel(unit, plural) {
var labels = {
'g':'g', 'kg':'kg', 'oz':'oz', 'lb':'lb',
'cup': plural ? 'cups' : 'cup',
'tablespoon':'tbsp', 'teaspoon':'tsp',
'ml':'ml', 'L':'L',
'quart': plural ? 'quarts' : 'quart',
'gallon': plural ? 'gallons' : 'gallon',
'pint': plural ? 'pints' : 'pint',
'fl oz':'fl oz',
};
return labels[unit] || unit;
}
function formatVU(value, unit) {
var label = unitLabel(unit, value !== 1);
var space = NO_SPACE[unit] ? '' : ' ';
return formatNumber(value) + space + label;
}
// ─── Smart Unit Selection ───────────────────────────────
function smartMetricWeight(g) {
return g >= 1000 ? { v: g / 1000, u: 'kg' } : { v: g, u: 'g' };
}
function smartImperialWeight(oz) {
return oz >= 16 ? { v: oz / 16, u: 'lb' } : { v: oz, u: 'oz' };
}
function smartMetricVolume(ml) {
return ml >= 1000 ? { v: ml / 1000, u: 'L' } : { v: ml, u: 'ml' };
}
function smartImperialVolume(ml) {
if (ml >= 236.588) return { v: ml / 236.588, u: 'cup' };
if (ml >= 14.787) return { v: ml / 14.787, u: 'tablespoon' };
return { v: ml / 4.929, u: 'teaspoon' };
}
// ─── Scale Computation ──────────────────────────────────
function computeScaledText(data, scaleFactor, imperial) {
var baseVal = data.base;
var isRange = Array.isArray(baseVal);
var prefix = data.approx ? '~' : '';
if (data.type === 'weight') {
if (isRange) {
var lo = baseVal[0] * scaleFactor;
var hi = baseVal[1] * scaleFactor;
var sLo, sHi;
if (imperial) {
sLo = smartImperialWeight(lo / 28.3495);
sHi = smartImperialWeight(hi / 28.3495);
} else {
sLo = smartMetricWeight(lo);
sHi = smartMetricWeight(hi);
}
if (sLo.u === sHi.u) {
var space = NO_SPACE[sLo.u] ? '' : ' ';
return prefix + formatNumber(sLo.v) + '-' + formatNumber(sHi.v) + space + unitLabel(sLo.u, sHi.v !== 1);
}
return prefix + formatVU(sLo.v, sLo.u) + '-' + formatVU(sHi.v, sHi.u);
}
var g = baseVal * scaleFactor;
var s;
if (imperial) {
s = smartImperialWeight(g / 28.3495);
} else {
s = smartMetricWeight(g);
}
return prefix + formatVU(s.v, s.u);
}
if (data.type === 'volume') {
if (isRange) {
var lo = baseVal[0] * scaleFactor;
var hi = baseVal[1] * scaleFactor;
var sLo, sHi;
if (imperial) {
sLo = smartImperialVolume(lo);
sHi = smartImperialVolume(hi);
} else {
sLo = smartMetricVolume(lo);
sHi = smartMetricVolume(hi);
}
if (sLo.u === sHi.u) {
var space = NO_SPACE[sLo.u] ? '' : ' ';
return prefix + formatNumber(sLo.v) + '-' + formatNumber(sHi.v) + space + unitLabel(sLo.u, sHi.v !== 1);
}
return prefix + formatVU(sLo.v, sLo.u) + '-' + formatVU(sHi.v, sHi.u);
}
var ml = baseVal * scaleFactor;
var s;
if (imperial) {
s = smartImperialVolume(ml);
} else {
s = smartMetricVolume(ml);
}
return prefix + formatVU(s.v, s.u);
}
if (data.type === 'count') {
if (isRange) {
var lo = baseVal[0] * scaleFactor;
var hi = baseVal[1] * scaleFactor;
return prefix + formatNumber(lo) + '-' + formatNumber(hi);
}
return prefix + formatNumber(baseVal * scaleFactor);
}
return null;
}
// ─── Update All Measurements ────────────────────────────
function updateAll() {
var spans = document.querySelectorAll('span.measurement');
spans.forEach(function (span) {
var type = span.getAttribute('data-measurement-type');
var isHybrid = span.getAttribute('data-hybrid') === 'true';
var scalableAttr, defaultAttr, altAttr, effectiveType;
if (isHybrid && measureByVolume && type === 'weight') {
// weight measurement in volume mode -> use volume data
scalableAttr = 'data-volume-scalable';
defaultAttr = 'data-volume-default';
altAttr = 'data-volume-alt';
effectiveType = 'volume';
} else if (isHybrid && !measureByVolume && type === 'volume') {
// volume measurement in weight mode -> use weight data
scalableAttr = 'data-weight-scalable';
defaultAttr = 'data-weight-default';
altAttr = 'data-weight-alt';
effectiveType = 'weight';
} else {
// default: use primary data
scalableAttr = 'data-scalable';
defaultAttr = 'data-default';
altAttr = 'data-alt';
effectiveType = type;
}
var imperial = !!showingAlt[effectiveType];
var scalableRaw = span.getAttribute(scalableAttr);
if (scalableRaw && scale !== 1) {
// Use scaling computation
try {
var data = JSON.parse(scalableRaw);
var text = computeScaledText(data, scale, imperial);
if (text) { span.textContent = text; return; }
} catch (e) {}
}
// No scaling or not scalable — use pre-computed text
var text = imperial
? span.getAttribute(altAttr)
: span.getAttribute(defaultAttr);
if (text) span.textContent = text;
});
}
// ─── Unit Toggle ────────────────────────────────────────
function toggleType(type) {
showingAlt[type] = !showingAlt[type];
updateAll();
var btn = document.querySelector('.measurement-toggle-btn[data-toggle-type="' + type + '"]');
if (btn) {
var labels = TYPE_LABELS[type];
btn.textContent = showingAlt[type] ? labels.toAlt : labels.toDefault;
}
}
// ─── Measure Mode Toggle ───────────────────────────────
function setMeasureMode(mode) {
measureByVolume = (mode === 'volume');
var btns = document.querySelectorAll('.measure-mode-btn');
btns.forEach(function (btn) {
if (btn.getAttribute('data-mode') === mode) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
updateAll();
}
// ─── Scale Toggle ───────────────────────────────────────
function setScale(newScale) {
scale = newScale;
updateAll();
updateScaleButtons();
}
function updateScaleButtons() {
var btns = document.querySelectorAll('.scale-btn');
btns.forEach(function (btn) {
var val = parseFloat(btn.getAttribute('data-scale'));
if (val === scale) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
// ─── Init ───────────────────────────────────────────────
function init() {
var container = document.querySelector('.measurement-toggles');
if (!container) return;
// Detect which types have toggleable content
var typeHasToggle = {};
var hasScalable = false;
var hasHybrid = false;
var spans = document.querySelectorAll('span.measurement');
spans.forEach(function (span) {
var type = span.getAttribute('data-measurement-type');
if (!type) return;
var def = span.getAttribute('data-default');
var alt = span.getAttribute('data-alt');
if (def !== alt) typeHasToggle[type] = true;
if (span.getAttribute('data-scalable')) hasScalable = true;
if (span.getAttribute('data-hybrid') === 'true') hasHybrid = true;
});
// Hybrid measurements need both weight and volume metric/imperial toggles
if (hasHybrid) {
typeHasToggle['volume'] = true;
typeHasToggle['weight'] = true;
}
var hasAny = false;
// Measure mode toggle (Weight vs Volume) — only if hybrid measurements exist
if (hasHybrid) {
hasAny = true;
var measureRow = document.createElement('div');
measureRow.className = 'toggle-row';
var measureLabel = document.createElement('span');
measureLabel.className = 'measure-label';
measureLabel.textContent = 'Measure:';
measureRow.appendChild(measureLabel);
var modes = [
{ mode: 'weight', label: 'Weight' },
{ mode: 'volume', label: 'Volume' },
];
modes.forEach(function (m) {
var btn = document.createElement('button');
btn.className = 'measure-mode-btn' + (m.mode === 'weight' ? ' active' : '');
btn.setAttribute('data-mode', m.mode);
btn.textContent = m.label;
btn.addEventListener('click', function () { setMeasureMode(m.mode); });
measureRow.appendChild(btn);
});
container.appendChild(measureRow);
}
// Unit toggle buttons
var unitRow = document.createElement('div');
unitRow.className = 'toggle-row';
var unitLabel = document.createElement('span');
unitLabel.className = 'unit-label';
unitLabel.textContent = 'Unit:';
unitRow.appendChild(unitLabel);
var typeOrder = ['temperature', 'weight', 'volume', 'dimension', 'time'];
typeOrder.forEach(function (type) {
if (!typeHasToggle[type]) return;
hasAny = true;
showingAlt[type] = false;
var btn = document.createElement('button');
btn.className = 'measurement-toggle-btn';
btn.setAttribute('data-toggle-type', type);
btn.textContent = TYPE_LABELS[type].toDefault;
btn.addEventListener('click', function () { toggleType(type); });
unitRow.appendChild(btn);
});
if (hasAny) {
container.appendChild(unitRow);
}
// Scale buttons
if (hasScalable) {
hasAny = true;
var scaleRow = document.createElement('div');
scaleRow.className = 'toggle-row';
var scaleLabel = document.createElement('span');
scaleLabel.className = 'scale-label';
scaleLabel.textContent = 'Scale:';
scaleRow.appendChild(scaleLabel);
var scales = [0.5, 1, 2, 3];
scales.forEach(function (s) {
var btn = document.createElement('button');
btn.className = 'scale-btn' + (s === 1 ? ' active' : '');
btn.setAttribute('data-scale', s);
btn.textContent = s + 'x';
btn.addEventListener('click', function () {
setScale(s);
var input = document.querySelector('.scale-input');
if (input) input.value = '';
});
scaleRow.appendChild(btn);
});
var input = document.createElement('input');
input.type = 'number';
input.className = 'scale-input';
input.placeholder = 'Custom';
input.min = '0.1';
input.step = '0.1';
input.addEventListener('input', function () {
var val = parseFloat(input.value);
if (val > 0 && !isNaN(val)) {
setScale(val);
}
});
scaleRow.appendChild(input);
container.appendChild(scaleRow);
}
if (hasAny) {
container.style.display = 'flex';
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();