| 1 |
(function () { |
| 2 |
'use strict'; |
| 3 |
|
| 4 |
var _typoKeys = { |
| 5 |
family:'font-family', weight:'font-weight', style:'font-style', |
| 6 |
decoration:'text-decoration', transform:'text-transform', |
| 7 |
sizeDesktop:'font-size-d', sizeTablet:'font-size-t', sizeMobile:'font-size-m', |
| 8 |
lineHeightDesktop:'line-height-d', lineHeightTablet:'line-height-t', lineHeightMobile:'line-height-m', |
| 9 |
letterSpacingDesktop:'letter-spacing-d', letterSpacingTablet:'letter-spacing-t', letterSpacingMobile:'letter-spacing-m', |
| 10 |
wordSpacingDesktop:'word-spacing-d', wordSpacingTablet:'word-spacing-t', wordSpacingMobile:'word-spacing-m' |
| 11 |
}; |
| 12 |
function typoCssVarsForEl(el, obj, prefix) { |
| 13 |
if (!obj || typeof obj !== 'object') return; |
| 14 |
Object.keys(_typoKeys).forEach(function (k) { |
| 15 |
var v = obj[k]; |
| 16 |
if (v === undefined || v === '' || v === null) return; |
| 17 |
if (k === 'sizeDesktop' || k === 'sizeTablet' || k === 'sizeMobile') v = v + (obj.sizeUnit || 'px'); |
| 18 |
else if (k === 'lineHeightDesktop' || k === 'lineHeightTablet' || k === 'lineHeightMobile') v = v + (obj.lineHeightUnit || ''); |
| 19 |
else if (k === 'letterSpacingDesktop' || k === 'letterSpacingTablet' || k === 'letterSpacingMobile') v = v + (obj.letterSpacingUnit || 'px'); |
| 20 |
else if (k === 'wordSpacingDesktop' || k === 'wordSpacingTablet' || k === 'wordSpacingMobile') v = v + (obj.wordSpacingUnit || 'px'); |
| 21 |
el.style.setProperty(prefix + _typoKeys[k], String(v)); |
| 22 |
}); |
| 23 |
} |
| 24 |
|
| 25 |
var ACTIVITY_MULT = {sedentary:1.2, light:1.375, moderate:1.55, active:1.725, very_active:1.9}; |
| 26 |
var GOAL_MULT = {lose_fast:0.75, lose_slow:0.85, maintain:1, gain_slow:1.1, gain_fast:1.2}; |
| 27 |
var MACRO_RATIOS = { |
| 28 |
lose_fast:[0.40,0.30,0.30], lose_slow:[0.35,0.35,0.30], maintain:[0.30,0.40,0.30], |
| 29 |
gain_slow:[0.30,0.45,0.25], gain_fast:[0.30,0.50,0.20] |
| 30 |
}; |
| 31 |
var ACTIVITY_OPTS = [ |
| 32 |
{label:'Sedentary (little/no exercise)', value:'sedentary'}, |
| 33 |
{label:'Light (1-3 days/week)', value:'light'}, |
| 34 |
{label:'Moderate (3-5 days/week)', value:'moderate'}, |
| 35 |
{label:'Active (6-7 days/week)', value:'active'}, |
| 36 |
{label:'Very Active (twice/day)', value:'very_active'} |
| 37 |
]; |
| 38 |
var GOAL_OPTS = [ |
| 39 |
{label:'Lose weight fast (–25%)', value:'lose_fast'}, |
| 40 |
{label:'Lose weight slow (–15%)', value:'lose_slow'}, |
| 41 |
{label:'Maintain weight', value:'maintain'}, |
| 42 |
{label:'Gain muscle slow (+10%)', value:'gain_slow'}, |
| 43 |
{label:'Gain muscle fast (+20%)', value:'gain_fast'} |
| 44 |
]; |
| 45 |
|
| 46 |
function calcMacros(gender, age, weightKg, heightCm, activity, goal) { |
| 47 |
var bmr = gender === 'female' |
| 48 |
? 10*weightKg + 6.25*heightCm - 5*age - 161 |
| 49 |
: 10*weightKg + 6.25*heightCm - 5*age + 5; |
| 50 |
var tdee = bmr * (ACTIVITY_MULT[activity] || 1.55); |
| 51 |
var calories = Math.round(tdee * (GOAL_MULT[goal] || 1)); |
| 52 |
var ratio = MACRO_RATIOS[goal] || MACRO_RATIOS['maintain']; |
| 53 |
return { |
| 54 |
calories: calories, |
| 55 |
protein: Math.round((calories * ratio[0]) / 4), |
| 56 |
carbs: Math.round((calories * ratio[1]) / 4), |
| 57 |
fat: Math.round((calories * ratio[2]) / 9), |
| 58 |
bmr: Math.round(bmr), |
| 59 |
tdee: Math.round(tdee) |
| 60 |
}; |
| 61 |
} |
| 62 |
|
| 63 |
function buildDonut(r, protClr, carbClr, fatClr) { |
| 64 |
var ns = 'http://www.w3.org/2000/svg'; |
| 65 |
var svg = document.createElementNS(ns, 'svg'); |
| 66 |
svg.setAttribute('viewBox','0 0 100 100'); |
| 67 |
svg.className = 'bkbg-mcr-donut-svg'; |
| 68 |
svg.style.cssText = 'width:110px;height:110px;display:block'; |
| 69 |
|
| 70 |
var total = r.protein*4 + r.carbs*4 + r.fat*9; |
| 71 |
var p1 = total > 0 ? r.protein*4/total : 0.33; |
| 72 |
var p2 = total > 0 ? r.carbs*4/total : 0.34; |
| 73 |
|
| 74 |
function makeArc(pct, offset, color) { |
| 75 |
var c = 2 * Math.PI * 40; |
| 76 |
var sd = c * (1 - pct); |
| 77 |
var el = document.createElementNS(ns, 'circle'); |
| 78 |
el.setAttribute('cx','50'); el.setAttribute('cy','50'); el.setAttribute('r','40'); |
| 79 |
el.setAttribute('fill','none'); el.setAttribute('stroke', color); el.setAttribute('stroke-width','18'); |
| 80 |
el.setAttribute('stroke-dasharray', c); el.setAttribute('stroke-dashoffset', sd); |
| 81 |
el.setAttribute('transform','rotate('+(offset*360-90)+' 50 50)'); |
| 82 |
return el; |
| 83 |
} |
| 84 |
svg.appendChild(makeArc(p1, 0, protClr)); |
| 85 |
svg.appendChild(makeArc(p2, p1, carbClr)); |
| 86 |
svg.appendChild(makeArc(1-p1-p2, p1+p2, fatClr)); |
| 87 |
|
| 88 |
var tVal = document.createElementNS(ns,'text'); |
| 89 |
tVal.setAttribute('x','50'); tVal.setAttribute('y','48'); tVal.setAttribute('text-anchor','middle'); |
| 90 |
tVal.setAttribute('font-size','12'); tVal.setAttribute('fill','#374151'); tVal.setAttribute('font-weight','700'); |
| 91 |
tVal.textContent = r.calories; |
| 92 |
var tLbl = document.createElementNS(ns,'text'); |
| 93 |
tLbl.setAttribute('x','50'); tLbl.setAttribute('y','60'); tLbl.setAttribute('text-anchor','middle'); |
| 94 |
tLbl.setAttribute('font-size','7'); tLbl.setAttribute('fill','#6b7280'); |
| 95 |
tLbl.textContent = 'kcal/day'; |
| 96 |
svg.appendChild(tVal); svg.appendChild(tLbl); |
| 97 |
return svg; |
| 98 |
} |
| 99 |
|
| 100 |
function initMacro(app) { |
| 101 |
var opts; |
| 102 |
try { opts = JSON.parse(app.getAttribute('data-opts') || '{}'); } catch(e) { opts = {}; } |
| 103 |
|
| 104 |
var accent = opts.accentColor || '#6c3fb5'; |
| 105 |
var cardBg = opts.cardBg || '#ffffff'; |
| 106 |
var calorieBg = opts.calorieBg || accent; |
| 107 |
var calorieColor = opts.calorieColor || '#ffffff'; |
| 108 |
var protClr = opts.proteinColor || '#3b82f6'; |
| 109 |
var carbClr = opts.carbColor || '#f59e0b'; |
| 110 |
var fatClr = opts.fatColor || '#ef4444'; |
| 111 |
var statBg = opts.statBg || '#f3f4f6'; |
| 112 |
var statBorder = opts.statBorder || '#e5e7eb'; |
| 113 |
var inputBorder = opts.inputBorder || '#e5e7eb'; |
| 114 |
var labelColor = opts.labelColor || '#374151'; |
| 115 |
var sectionBg = opts.sectionBg || ''; |
| 116 |
|
| 117 |
var cardRadius = (opts.cardRadius || 16) + 'px'; |
| 118 |
var inputRadius = (opts.inputRadius || 8) + 'px'; |
| 119 |
var maxWidth = (opts.maxWidth || 580) + 'px'; |
| 120 |
var calorieSize = (opts.calorieSize || 52) + 'px'; |
| 121 |
var titleSize = (opts.titleSize || 28) + 'px'; |
| 122 |
var paddingTop = (opts.paddingTop != null ? opts.paddingTop : 60) + 'px'; |
| 123 |
var paddingBottom = (opts.paddingBottom != null ? opts.paddingBottom : 60) + 'px'; |
| 124 |
|
| 125 |
var showPieChart = opts.showPieChart !== false; |
| 126 |
|
| 127 |
app.style.setProperty('--bkbg-mcr-accent', accent); |
| 128 |
typoCssVarsForEl(app, opts.titleTypo, '--bkbg-mcr-tt-'); |
| 129 |
typoCssVarsForEl(app, opts.subtitleTypo, '--bkbg-mcr-st-'); |
| 130 |
app.style.paddingTop = paddingTop; |
| 131 |
app.style.paddingBottom = paddingBottom; |
| 132 |
if (sectionBg) app.style.background = sectionBg; |
| 133 |
|
| 134 |
var wrap = document.createElement('div'); |
| 135 |
wrap.className = 'bkbg-mcr-wrap'; |
| 136 |
wrap.style.cssText = 'background:' + cardBg + ';border-radius:' + cardRadius + ';max-width:' + maxWidth; |
| 137 |
app.appendChild(wrap); |
| 138 |
|
| 139 |
// Header |
| 140 |
if ((opts.showTitle && opts.title) || (opts.showSubtitle && opts.subtitle)) { |
| 141 |
var hdr = document.createElement('div'); hdr.className = 'bkbg-mcr-header'; |
| 142 |
if (opts.showTitle && opts.title) { |
| 143 |
var t = document.createElement('div'); t.className = 'bkbg-mcr-title'; |
| 144 |
if (opts.titleColor) t.style.color = opts.titleColor; |
| 145 |
t.textContent = opts.title; hdr.appendChild(t); |
| 146 |
} |
| 147 |
if (opts.showSubtitle && opts.subtitle) { |
| 148 |
var s = document.createElement('div'); s.className = 'bkbg-mcr-subtitle'; |
| 149 |
if (opts.subtitleColor) s.style.color = opts.subtitleColor; |
| 150 |
s.textContent = opts.subtitle; hdr.appendChild(s); |
| 151 |
} |
| 152 |
wrap.appendChild(hdr); |
| 153 |
} |
| 154 |
|
| 155 |
// State |
| 156 |
var state = { |
| 157 |
unit: opts.defaultUnit || 'metric', |
| 158 |
gender: opts.defaultGender || 'male', |
| 159 |
age: opts.defaultAge || 30, |
| 160 |
weight: opts.defaultWeight || 75, |
| 161 |
height: opts.defaultHeight || 175, |
| 162 |
activity: opts.defaultActivity || 'moderate', |
| 163 |
goal: opts.defaultGoal || 'maintain' |
| 164 |
}; |
| 165 |
|
| 166 |
function inputStyle() { return 'border:1.5px solid '+inputBorder+';border-radius:'+inputRadius; } |
| 167 |
function makeLabel(text) { |
| 168 |
var l = document.createElement('label'); |
| 169 |
l.className = 'bkbg-mcr-label'; l.style.color = labelColor; |
| 170 |
l.textContent = text; return l; |
| 171 |
} |
| 172 |
|
| 173 |
// Unit toggle row |
| 174 |
var toggleRow = document.createElement('div'); toggleRow.className = 'bkbg-mcr-toggle-row'; |
| 175 |
|
| 176 |
function makeToggle(options, current, onChange, labelText) { |
| 177 |
var grp = document.createElement('div'); grp.className = 'bkbg-mcr-toggle-group'; |
| 178 |
var lbl = makeLabel(labelText); grp.appendChild(lbl); |
| 179 |
var btns = document.createElement('div'); |
| 180 |
btns.className = 'bkbg-mcr-toggle-btns'; |
| 181 |
btns.style.cssText = 'border:1.5px solid '+inputBorder+';border-radius:8px;overflow:hidden'; |
| 182 |
var btnEls = []; |
| 183 |
options.forEach(function(o) { |
| 184 |
var b = document.createElement('button'); |
| 185 |
b.className = 'bkbg-mcr-toggle-btn'; b.dataset.val = o.value; |
| 186 |
b.textContent = o.label; |
| 187 |
b.style.cssText = 'background:' + (current===o.value?accent:'#fff') + ';color:' + (current===o.value?'#fff':'#374151'); |
| 188 |
b.addEventListener('click', function() { |
| 189 |
current = o.value; |
| 190 |
btnEls.forEach(function(be) { |
| 191 |
be.style.background = be.dataset.val===current ? accent : '#fff'; |
| 192 |
be.style.color = be.dataset.val===current ? '#fff' : '#374151'; |
| 193 |
}); |
| 194 |
onChange(o.value); |
| 195 |
refresh(); |
| 196 |
}); |
| 197 |
btnEls.push(b); btns.appendChild(b); |
| 198 |
}); |
| 199 |
grp.appendChild(btns); return grp; |
| 200 |
} |
| 201 |
|
| 202 |
var unitToggle = makeToggle([{label:'Metric',value:'metric'},{label:'Imperial',value:'imperial'}], state.unit, function(v){ state.unit=v; updateLabels(); }, 'Unit'); |
| 203 |
var genderToggle = makeToggle([{label:'Male',value:'male'},{label:'Female',value:'female'}], state.gender, function(v){ state.gender=v; }, 'Gender'); |
| 204 |
toggleRow.appendChild(unitToggle); toggleRow.appendChild(genderToggle); |
| 205 |
wrap.appendChild(toggleRow); |
| 206 |
|
| 207 |
// Age / Weight / Height grid |
| 208 |
var grid3 = document.createElement('div'); grid3.className = 'bkbg-mcr-grid-3'; |
| 209 |
|
| 210 |
function makeNumberField(labelText, val, min, onChange) { |
| 211 |
var field = document.createElement('div'); |
| 212 |
var lbl = makeLabel(labelText); field.appendChild(lbl); |
| 213 |
var inp = document.createElement('input'); |
| 214 |
inp.type = 'number'; inp.value = val; inp.min = min; |
| 215 |
inp.className = 'bkbg-mcr-input'; inp.style.cssText = inputStyle(); |
| 216 |
inp.addEventListener('input', function() { onChange(parseFloat(inp.value) || 0); refresh(); }); |
| 217 |
field.appendChild(inp); return { field:field, inp:inp }; |
| 218 |
} |
| 219 |
|
| 220 |
var ageF = makeNumberField('Age', state.age, 10, function(v){state.age=v;}); |
| 221 |
var weightF = makeNumberField('Weight (kg)', state.weight, 30, function(v){state.weight=v;}); |
| 222 |
var heightF = makeNumberField('Height (cm)', state.height, 50, function(v){state.height=v;}); |
| 223 |
|
| 224 |
function updateLabels() { |
| 225 |
weightF.inp.previousSibling.textContent = state.unit==='imperial' ? 'Weight (lbs)' : 'Weight (kg)'; |
| 226 |
heightF.inp.previousSibling.textContent = state.unit==='imperial' ? 'Height (in)' : 'Height (cm)'; |
| 227 |
} |
| 228 |
|
| 229 |
grid3.appendChild(ageF.field); grid3.appendChild(weightF.field); grid3.appendChild(heightF.field); |
| 230 |
wrap.appendChild(grid3); |
| 231 |
|
| 232 |
// Activity + Goal selects |
| 233 |
var grid2 = document.createElement('div'); grid2.className = 'bkbg-mcr-grid-2'; |
| 234 |
function makeSelect(labelText, options, val, onChange) { |
| 235 |
var field = document.createElement('div'); |
| 236 |
var lbl = makeLabel(labelText); field.appendChild(lbl); |
| 237 |
var sel = document.createElement('select'); |
| 238 |
sel.className = 'bkbg-mcr-select'; sel.style.cssText = inputStyle(); |
| 239 |
options.forEach(function(o) { var op=document.createElement('option'); op.value=o.value; op.textContent=o.label; if(o.value===val) op.selected=true; sel.appendChild(op); }); |
| 240 |
sel.addEventListener('change', function() { onChange(sel.value); refresh(); }); |
| 241 |
field.appendChild(sel); return field; |
| 242 |
} |
| 243 |
grid2.appendChild(makeSelect('Activity Level', ACTIVITY_OPTS, state.activity, function(v){state.activity=v;})); |
| 244 |
grid2.appendChild(makeSelect('Goal', GOAL_OPTS, state.goal, function(v){state.goal=v;})); |
| 245 |
wrap.appendChild(grid2); |
| 246 |
|
| 247 |
// Calorie result card |
| 248 |
var calorieCard = document.createElement('div'); calorieCard.className = 'bkbg-mcr-calorie-card'; |
| 249 |
calorieCard.style.cssText = 'background:' + calorieBg + ';color:' + calorieColor; |
| 250 |
var cLbl = document.createElement('div'); cLbl.className = 'bkbg-mcr-calorie-lbl'; cLbl.textContent = 'Daily Calories'; |
| 251 |
var cVal = document.createElement('div'); cVal.className = 'bkbg-mcr-calorie-val'; cVal.style.fontSize = calorieSize; |
| 252 |
var cSub = document.createElement('div'); cSub.className = 'bkbg-mcr-calorie-sub'; |
| 253 |
calorieCard.appendChild(cLbl); calorieCard.appendChild(cVal); calorieCard.appendChild(cSub); |
| 254 |
wrap.appendChild(calorieCard); |
| 255 |
|
| 256 |
// Macros row |
| 257 |
var macrosRow = document.createElement('div'); macrosRow.className = 'bkbg-mcr-macros-row'; |
| 258 |
|
| 259 |
var donutWrap; |
| 260 |
if (showPieChart) { donutWrap = document.createElement('div'); donutWrap.className = 'bkbg-mcr-donut'; macrosRow.appendChild(donutWrap); } |
| 261 |
|
| 262 |
var macroList = document.createElement('div'); macroList.className = 'bkbg-mcr-macro-list'; |
| 263 |
var macroItems = [ |
| 264 |
{key:'protein', label:'Protein', unit:'g', note:'(4 kcal/g)', color:protClr}, |
| 265 |
{key:'carbs', label:'Carbohydrates', unit:'g', note:'(4 kcal/g)', color:carbClr}, |
| 266 |
{key:'fat', label:'Fat', unit:'g', note:'(9 kcal/g)', color:fatClr} |
| 267 |
]; |
| 268 |
var macroValEls = {}; |
| 269 |
macroItems.forEach(function(m) { |
| 270 |
var item = document.createElement('div'); item.className = 'bkbg-mcr-macro-item'; |
| 271 |
item.style.cssText = 'background:' + statBg + ';border:1px solid ' + statBorder; |
| 272 |
var left = document.createElement('div'); left.className = 'bkbg-mcr-macro-left'; |
| 273 |
var dot = document.createElement('div'); dot.className = 'bkbg-mcr-macro-dot'; dot.style.background = m.color; |
| 274 |
var name = document.createElement('span'); name.className = 'bkbg-mcr-macro-name'; name.style.color = labelColor; name.textContent = m.label; |
| 275 |
var note = document.createElement('span'); note.className = 'bkbg-mcr-macro-note'; note.textContent = m.note; |
| 276 |
left.appendChild(dot); left.appendChild(name); left.appendChild(note); |
| 277 |
var val = document.createElement('span'); val.className = 'bkbg-mcr-macro-val'; val.style.color = m.color; |
| 278 |
macroValEls[m.key] = val; |
| 279 |
item.appendChild(left); item.appendChild(val); macroList.appendChild(item); |
| 280 |
}); |
| 281 |
macrosRow.appendChild(macroList); |
| 282 |
wrap.appendChild(macrosRow); |
| 283 |
|
| 284 |
// BMR / TDEE stats |
| 285 |
var statRow = document.createElement('div'); statRow.className = 'bkbg-mcr-stat-row'; |
| 286 |
var bmrStat = document.createElement('div'); bmrStat.className = 'bkbg-mcr-stat'; bmrStat.style.cssText = 'background:'+statBg+';border:1px solid '+statBorder; |
| 287 |
var tdeeStat = document.createElement('div'); tdeeStat.className = 'bkbg-mcr-stat'; tdeeStat.style.cssText = 'background:'+statBg+';border:1px solid '+statBorder; |
| 288 |
var bmrV = document.createElement('div'); bmrV.className = 'bkbg-mcr-stat-val'; bmrV.style.color = '#111827'; |
| 289 |
var bmrL = document.createElement('div'); bmrL.className = 'bkbg-mcr-stat-lbl'; bmrL.style.color = '#6b7280'; bmrL.textContent = 'BMR (base metabolic rate)'; |
| 290 |
var tdeeV = document.createElement('div'); tdeeV.className = 'bkbg-mcr-stat-val'; tdeeV.style.color = '#111827'; |
| 291 |
var tdeeL = document.createElement('div'); tdeeL.className = 'bkbg-mcr-stat-lbl'; tdeeL.style.color = '#6b7280'; tdeeL.textContent = 'TDEE (maintenance calories)'; |
| 292 |
bmrStat.appendChild(bmrV); bmrStat.appendChild(bmrL); |
| 293 |
tdeeStat.appendChild(tdeeV); tdeeStat.appendChild(tdeeL); |
| 294 |
statRow.appendChild(bmrStat); statRow.appendChild(tdeeStat); |
| 295 |
wrap.appendChild(statRow); |
| 296 |
|
| 297 |
function refresh() { |
| 298 |
var weightKg = state.unit === 'imperial' ? state.weight * 0.453592 : state.weight; |
| 299 |
var heightCm = state.unit === 'imperial' ? state.height * 2.54 : state.height; |
| 300 |
var r = calcMacros(state.gender, state.age, weightKg, heightCm, state.activity, state.goal); |
| 301 |
|
| 302 |
cVal.textContent = r.calories; |
| 303 |
cSub.textContent = 'BMR: ' + r.bmr + ' · TDEE: ' + r.tdee + ' kcal'; |
| 304 |
macroValEls.protein.textContent = r.protein + 'g'; |
| 305 |
macroValEls.carbs.textContent = r.carbs + 'g'; |
| 306 |
macroValEls.fat.textContent = r.fat + 'g'; |
| 307 |
bmrV.textContent = r.bmr; |
| 308 |
tdeeV.textContent = r.tdee; |
| 309 |
|
| 310 |
if (showPieChart && donutWrap) { |
| 311 |
donutWrap.innerHTML = ''; |
| 312 |
donutWrap.appendChild(buildDonut(r, protClr, carbClr, fatClr)); |
| 313 |
} |
| 314 |
} |
| 315 |
|
| 316 |
refresh(); |
| 317 |
} |
| 318 |
|
| 319 |
document.querySelectorAll('.bkbg-mcr-app').forEach(initMacro); |
| 320 |
})(); |
| 321 |
|