feat: better supported hybrid units
This commit is contained in:
parent
93837aaf20
commit
bac7a14f95
4 changed files with 273 additions and 8 deletions
|
|
@ -141,7 +141,7 @@
|
||||||
gap: var(--space-tight);
|
gap: var(--space-tight);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.measurement-toggle-btn, .scale-btn {
|
.measurement-toggle-btn, .scale-btn, .measure-mode-btn {
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
border: 1px solid var(--color-border-default);
|
border: 1px solid var(--color-border-default);
|
||||||
|
|
@ -151,16 +151,26 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.15s, border-color 0.15s;
|
transition: background-color 0.15s, border-color 0.15s;
|
||||||
}
|
}
|
||||||
.measurement-toggle-btn:hover, .scale-btn:hover {
|
.measurement-toggle-btn:hover, .scale-btn:hover, .measure-mode-btn:hover {
|
||||||
border-color: var(--color-border-focus);
|
border-color: var(--color-border-focus);
|
||||||
background: var(--color-text-link);
|
background: var(--color-text-link);
|
||||||
color: var(--color-bg-page);
|
color: var(--color-bg-page);
|
||||||
}
|
}
|
||||||
|
.measure-mode-btn.active {
|
||||||
|
background: var(--color-text-link);
|
||||||
|
color: var(--color-bg-page);
|
||||||
|
border-color: var(--color-text-link);
|
||||||
|
}
|
||||||
.scale-btn.active {
|
.scale-btn.active {
|
||||||
background: var(--color-text-link);
|
background: var(--color-text-link);
|
||||||
color: var(--color-bg-page);
|
color: var(--color-bg-page);
|
||||||
border-color: var(--color-text-link);
|
border-color: var(--color-text-link);
|
||||||
}
|
}
|
||||||
|
.measure-label {
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
.scale-label {
|
.scale-label {
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
|
|
||||||
var showingAlt = {};
|
var showingAlt = {};
|
||||||
var scale = 1;
|
var scale = 1;
|
||||||
|
var measureByVolume = false;
|
||||||
|
|
||||||
var TYPE_LABELS = {
|
var TYPE_LABELS = {
|
||||||
temperature: { toAlt: '°F', toDefault: '°C' },
|
temperature: { toAlt: '°F', toDefault: '°C' },
|
||||||
|
|
@ -157,8 +158,17 @@
|
||||||
var spans = document.querySelectorAll('span.measurement');
|
var spans = document.querySelectorAll('span.measurement');
|
||||||
spans.forEach(function (span) {
|
spans.forEach(function (span) {
|
||||||
var type = span.getAttribute('data-measurement-type');
|
var type = span.getAttribute('data-measurement-type');
|
||||||
var imperial = !!showingAlt[type];
|
var isHybrid = span.getAttribute('data-hybrid') === 'true';
|
||||||
var scalableRaw = span.getAttribute('data-scalable');
|
var useVolumeMode = isHybrid && measureByVolume;
|
||||||
|
|
||||||
|
var effectiveType = useVolumeMode ? 'volume' : type;
|
||||||
|
var imperial = !!showingAlt[effectiveType];
|
||||||
|
|
||||||
|
var scalableAttr = useVolumeMode ? 'data-volume-scalable' : 'data-scalable';
|
||||||
|
var defaultAttr = useVolumeMode ? 'data-volume-default' : 'data-default';
|
||||||
|
var altAttr = useVolumeMode ? 'data-volume-alt' : 'data-alt';
|
||||||
|
|
||||||
|
var scalableRaw = span.getAttribute(scalableAttr);
|
||||||
|
|
||||||
if (scalableRaw && scale !== 1) {
|
if (scalableRaw && scale !== 1) {
|
||||||
// Use scaling computation
|
// Use scaling computation
|
||||||
|
|
@ -171,8 +181,8 @@
|
||||||
|
|
||||||
// No scaling or not scalable — use pre-computed text
|
// No scaling or not scalable — use pre-computed text
|
||||||
var text = imperial
|
var text = imperial
|
||||||
? span.getAttribute('data-alt')
|
? span.getAttribute(altAttr)
|
||||||
: span.getAttribute('data-default');
|
: span.getAttribute(defaultAttr);
|
||||||
if (text) span.textContent = text;
|
if (text) span.textContent = text;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -190,6 +200,23 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 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 ───────────────────────────────────────
|
// ─── Scale Toggle ───────────────────────────────────────
|
||||||
|
|
||||||
function setScale(newScale) {
|
function setScale(newScale) {
|
||||||
|
|
@ -219,6 +246,7 @@
|
||||||
// Detect which types have toggleable content
|
// Detect which types have toggleable content
|
||||||
var typeHasToggle = {};
|
var typeHasToggle = {};
|
||||||
var hasScalable = false;
|
var hasScalable = false;
|
||||||
|
var hasHybrid = false;
|
||||||
var spans = document.querySelectorAll('span.measurement');
|
var spans = document.querySelectorAll('span.measurement');
|
||||||
spans.forEach(function (span) {
|
spans.forEach(function (span) {
|
||||||
var type = span.getAttribute('data-measurement-type');
|
var type = span.getAttribute('data-measurement-type');
|
||||||
|
|
@ -227,10 +255,43 @@
|
||||||
var alt = span.getAttribute('data-alt');
|
var alt = span.getAttribute('data-alt');
|
||||||
if (def !== alt) typeHasToggle[type] = true;
|
if (def !== alt) typeHasToggle[type] = true;
|
||||||
if (span.getAttribute('data-scalable')) hasScalable = true;
|
if (span.getAttribute('data-scalable')) hasScalable = true;
|
||||||
|
if (span.getAttribute('data-hybrid') === 'true') hasHybrid = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hybrid measurements in volume mode need the volume metric/imperial toggle
|
||||||
|
if (hasHybrid) {
|
||||||
|
typeHasToggle['volume'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
var hasAny = false;
|
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
|
// Unit toggle buttons
|
||||||
var unitRow = document.createElement('div');
|
var unitRow = document.createElement('div');
|
||||||
unitRow.className = 'toggle-row';
|
unitRow.className = 'toggle-row';
|
||||||
|
|
|
||||||
|
|
@ -245,3 +245,82 @@ describe('tools section scaling', () => {
|
||||||
expect(countScalables.length).toBeGreaterThanOrEqual(1);
|
expect(countScalables.length).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Hybrid Weight/Volume Measurements ───────────────────
|
||||||
|
|
||||||
|
function getHybridAttrs(html) {
|
||||||
|
const matches = [];
|
||||||
|
const re = /data-hybrid="true"[^>]*data-volume-default="([^"]*)"[^>]*data-volume-alt="([^"]*)"(?:[^>]*data-volume-scalable="([^"]*)")?/g;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(html)) !== null) {
|
||||||
|
matches.push({
|
||||||
|
volumeDefault: decodeHtmlEntities(m[1]),
|
||||||
|
volumeAlt: decodeHtmlEntities(m[2]),
|
||||||
|
volumeScalable: m[3] ? JSON.parse(decodeHtmlEntities(m[3])) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('hybrid weight/volume measurements', () => {
|
||||||
|
it('adds hybrid data attributes for weight with volume alt', () => {
|
||||||
|
const md = '- unsalted butter 80g (6 tablespoons)';
|
||||||
|
const html = render(md);
|
||||||
|
expect(html).toContain('data-hybrid="true"');
|
||||||
|
expect(html).toContain('data-volume-default=');
|
||||||
|
expect(html).toContain('data-volume-alt=');
|
||||||
|
expect(html).toContain('data-volume-scalable=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT add hybrid attributes for weight-to-weight alt', () => {
|
||||||
|
const md = '- butter 227g (8 oz)';
|
||||||
|
const html = render(md);
|
||||||
|
expect(html).not.toContain('data-hybrid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates correct volume texts for simple hybrid', () => {
|
||||||
|
const md = '- unsalted butter 80g (6 tablespoons)';
|
||||||
|
const html = render(md);
|
||||||
|
const hybrids = getHybridAttrs(html);
|
||||||
|
expect(hybrids.length).toBe(1);
|
||||||
|
// 6 tbsp = 6 * 14.787 = 88.722ml -> metric: "88.7ml"
|
||||||
|
expect(hybrids[0].volumeDefault).toBe('88.7ml');
|
||||||
|
expect(hybrids[0].volumeAlt).toBe('6 tbsp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles nested hybrid with intermediate', () => {
|
||||||
|
const md = '- milk 860g (800mL (3 1/3 cups))';
|
||||||
|
const html = render(md);
|
||||||
|
const hybrids = getHybridAttrs(html);
|
||||||
|
expect(hybrids.length).toBe(1);
|
||||||
|
expect(hybrids[0].volumeDefault).toBe('800ml');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes volume scalable data with base in ml', () => {
|
||||||
|
const md = '- unsalted butter 80g (6 tablespoons)';
|
||||||
|
const html = render(md);
|
||||||
|
const hybrids = getHybridAttrs(html);
|
||||||
|
expect(hybrids.length).toBe(1);
|
||||||
|
expect(hybrids[0].volumeScalable).toBeDefined();
|
||||||
|
expect(hybrids[0].volumeScalable.type).toBe('volume');
|
||||||
|
// 6 tablespoons = 6 * 14.787 = 88.722 ml
|
||||||
|
expect(hybrids[0].volumeScalable.base).toBeCloseTo(88.722, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes volume scalable data for nested hybrid', () => {
|
||||||
|
const md = '- milk 860g (800mL (3 1/3 cups))';
|
||||||
|
const html = render(md);
|
||||||
|
const hybrids = getHybridAttrs(html);
|
||||||
|
expect(hybrids.length).toBe(1);
|
||||||
|
expect(hybrids[0].volumeScalable.type).toBe('volume');
|
||||||
|
expect(hybrids[0].volumeScalable.base).toBeCloseTo(800, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still has weight data attributes alongside hybrid attrs', () => {
|
||||||
|
const md = '- unsalted butter 80g (6 tablespoons)';
|
||||||
|
const html = render(md);
|
||||||
|
// Should still have the weight default/alt
|
||||||
|
expect(html).toContain('data-default="80g"');
|
||||||
|
expect(html).toContain('data-measurement-type="weight"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
* Toggle: imperial units, expanded times
|
* Toggle: imperial units, expanded times
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { findAllMeasurements } = require('./matcher');
|
const { findAllMeasurements, unitType } = require('./matcher');
|
||||||
|
|
||||||
// ─── Number Formatting ──────────────────────────────────
|
// ─── Number Formatting ──────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -204,6 +204,105 @@ function computeScaleData(measurement) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Hybrid (Weight+Volume) Detection ───────────────────
|
||||||
|
|
||||||
|
function getHybridVolumeData(measurement) {
|
||||||
|
if (measurement.type !== 'weight') return null;
|
||||||
|
|
||||||
|
const { alt, intermediate } = measurement;
|
||||||
|
|
||||||
|
if (intermediate && alt) {
|
||||||
|
const intType = unitType(intermediate.unit);
|
||||||
|
const altType = unitType(alt.unit);
|
||||||
|
if (intType === 'volume' || altType === 'volume') {
|
||||||
|
return {
|
||||||
|
metricSource: intType === 'volume' ? intermediate : alt,
|
||||||
|
imperialSource: altType === 'volume' ? alt : intermediate,
|
||||||
|
hasIntermediate: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alt) {
|
||||||
|
if (unitType(alt.unit) === 'volume') {
|
||||||
|
return { metricSource: alt, imperialSource: alt, hasIntermediate: false };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateHybridVolumeTexts(measurement, hybridData) {
|
||||||
|
const { metricSource, imperialSource, hasIntermediate } = hybridData;
|
||||||
|
const approximate = measurement.approximate;
|
||||||
|
const prefix = approximate ? '~' : '';
|
||||||
|
|
||||||
|
if (hasIntermediate) {
|
||||||
|
const metric = toMetricValue(metricSource.amount.value, metricSource.unit);
|
||||||
|
const imperial = toImperialValue(imperialSource.amount.value, imperialSource.unit);
|
||||||
|
return {
|
||||||
|
volumeDefault: prefix + formatValueUnit(metric.value, metric.unit),
|
||||||
|
volumeAlt: prefix + formatValueUnit(imperial.value, imperial.unit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { amount, unit } = metricSource;
|
||||||
|
|
||||||
|
if (amount.min !== undefined) {
|
||||||
|
const mMin = toMetricValue(amount.min.value, unit);
|
||||||
|
const mMax = toMetricValue(amount.max.value, unit);
|
||||||
|
const iMin = toImperialValue(amount.min.value, unit);
|
||||||
|
const iMax = toImperialValue(amount.max.value, unit);
|
||||||
|
const rPrefix = (approximate || amount.min.approximate) ? '~' : '';
|
||||||
|
|
||||||
|
let volumeDefault, volumeAlt;
|
||||||
|
|
||||||
|
if (mMin.unit === mMax.unit) {
|
||||||
|
const space = NO_SPACE_UNITS.has(mMin.unit) ? '' : ' ';
|
||||||
|
volumeDefault = rPrefix + formatNumber(mMin.value) + '-' + formatNumber(mMax.value) + space + unitLabel(mMin.unit, mMax.value !== 1);
|
||||||
|
} else {
|
||||||
|
volumeDefault = rPrefix + formatValueUnit(mMin.value, mMin.unit) + '-' + formatValueUnit(mMax.value, mMax.unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iMin.unit === iMax.unit) {
|
||||||
|
const space = NO_SPACE_UNITS.has(iMin.unit) ? '' : ' ';
|
||||||
|
volumeAlt = rPrefix + formatNumber(iMin.value) + '-' + formatNumber(iMax.value) + space + unitLabel(iMin.unit, iMax.value !== 1);
|
||||||
|
} else {
|
||||||
|
volumeAlt = rPrefix + formatValueUnit(iMin.value, iMin.unit) + '-' + formatValueUnit(iMax.value, iMax.unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { volumeDefault, volumeAlt };
|
||||||
|
}
|
||||||
|
|
||||||
|
const metric = toMetricValue(amount.value, unit);
|
||||||
|
const imperial = toImperialValue(amount.value, unit);
|
||||||
|
return {
|
||||||
|
volumeDefault: prefix + formatValueUnit(metric.value, metric.unit),
|
||||||
|
volumeAlt: prefix + formatValueUnit(imperial.value, imperial.unit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeHybridVolumeScaleData(measurement, hybridData) {
|
||||||
|
const source = hybridData.metricSource;
|
||||||
|
const approximate = measurement.approximate;
|
||||||
|
|
||||||
|
if (source.amount.min !== undefined) {
|
||||||
|
return {
|
||||||
|
base: [toBaseMl(source.amount.min.value, source.unit), toBaseMl(source.amount.max.value, source.unit)],
|
||||||
|
type: 'volume',
|
||||||
|
approx: approximate || source.amount.min.approximate || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
base: toBaseMl(source.amount.value, source.unit),
|
||||||
|
type: 'volume',
|
||||||
|
approx: approximate || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Time Formatting ────────────────────────────────────
|
// ─── Time Formatting ────────────────────────────────────
|
||||||
|
|
||||||
function toMinutes(value, unit) {
|
function toMinutes(value, unit) {
|
||||||
|
|
@ -421,6 +520,20 @@ function replaceMeasurementsInToken(token, Token, inToolsSection) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add hybrid data attributes for weight measurements with volume alternatives
|
||||||
|
const hybridData = getHybridVolumeData(m);
|
||||||
|
if (hybridData) {
|
||||||
|
const { volumeDefault, volumeAlt } = generateHybridVolumeTexts(m, hybridData);
|
||||||
|
open.attrSet('data-hybrid', 'true');
|
||||||
|
open.attrSet('data-volume-default', volumeDefault);
|
||||||
|
open.attrSet('data-volume-alt', volumeAlt);
|
||||||
|
|
||||||
|
const volumeScaleData = computeHybridVolumeScaleData(m, hybridData);
|
||||||
|
if (volumeScaleData) {
|
||||||
|
open.attrSet('data-volume-scalable', JSON.stringify(volumeScaleData));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
newTokens.push(open);
|
newTokens.push(open);
|
||||||
|
|
||||||
// Display metric-normalized text by default
|
// Display metric-normalized text by default
|
||||||
|
|
@ -506,3 +619,5 @@ module.exports.toImperialValue = toImperialValue;
|
||||||
module.exports.generateTexts = generateTexts;
|
module.exports.generateTexts = generateTexts;
|
||||||
module.exports.collapsedTime = collapsedTime;
|
module.exports.collapsedTime = collapsedTime;
|
||||||
module.exports.expandedTime = expandedTime;
|
module.exports.expandedTime = expandedTime;
|
||||||
|
module.exports.getHybridVolumeData = getHybridVolumeData;
|
||||||
|
module.exports.generateHybridVolumeTexts = generateHybridVolumeTexts;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue