fc0441348d
gates / gates (push) Successful in 6m47s
Empty or tiny selections drew a ~2.3 GB box whose base system (1,636 MB) no real box has; the bar is now floored at the smallest named size (8 GB) for its parts only — the recommendation and the rule are unchanged. Grouping applies only to two or more leftover apps; a ninth app is shown by name in the group's colour. Tests: 12, the new ones red-proofed.
316 lines
17 KiB
JavaScript
316 lines
17 KiB
JavaScript
/* sizing.js — the machine estimator (/gepigeny, /en/sizing).
|
|
*
|
|
* THE RULE lives in guestMib(), minBoxGb() and estimate() below, and nowhere else on the site. Every number it
|
|
* uses comes from /assets/sizing-data.json (built by scripts/build_sizing_web.py from the 2026-10-13 N100
|
|
* measurement); this file holds none of its own — scripts/test_build_sizing_web.py checks that.
|
|
*
|
|
* typical = platform + sum of the selected apps' typical figures
|
|
* peak = platform + sum of their peaks (all at peak at once: pessimistic on purpose)
|
|
* per box size: guest memory by the installer's rule (guestMib), then
|
|
* well peak * headroom <= guest
|
|
* tight peak <= guest < peak * headroom
|
|
* no guest < peak
|
|
* the memory bar: minBoxGb() inverts guestMib() by SEARCHING it — the smallest box memory whose guest
|
|
* holds peak * headroom (the bar's length) and peak (thin marker). It never restates the installer's rule.
|
|
* v3: the bar is split into what that box memory is used for, every part from the same rule:
|
|
* base system = box - guest(box) · Felhom = platform · each app = its peak · spare = guest(box) - peak
|
|
* so the parts add up to the box exactly (scripts/test_build_sizing_web.py proves it).
|
|
* v3.1: the bar is never drawn for a box smaller than the smallest size the page names (boxes_gb[0]);
|
|
* below it, the installer's ceiling clause would show a base system no real box has.
|
|
*
|
|
* The page text is not here: each page carries its own strings (#sizer-strings), so this file is the
|
|
* same for both languages.
|
|
*/
|
|
(function (root) {
|
|
'use strict';
|
|
|
|
function guestMib(data, gb) {
|
|
var r = data.guest_rule;
|
|
var total = gb * 1024 - r.memtotal_shortfall_mib;
|
|
var mem = total - r.reserve_mib;
|
|
if (mem > total - r.max_gap_mib) { mem = total - r.max_gap_mib; }
|
|
if (mem < r.min_mib) { mem = r.min_mib; }
|
|
if (mem > total - r.ceiling_gap_mib) { mem = total - r.ceiling_gap_mib; }
|
|
return mem;
|
|
}
|
|
|
|
// The smallest box memory (GB, not rounded to a product size) whose guest holds needMib. guestMib() never
|
|
// decreases as the box grows, so a bisection on it is exact to 1 MiB; it uses the forward rule only.
|
|
function minBoxGb(data, needMib) {
|
|
var lo = 0, hi = 1;
|
|
while (guestMib(data, hi) < needMib) { hi = hi * 2; }
|
|
while (hi - lo > 1 / 1024) {
|
|
var mid = (lo + hi) / 2;
|
|
if (guestMib(data, mid) >= needMib) { hi = mid; } else { lo = mid; }
|
|
}
|
|
return hi;
|
|
}
|
|
|
|
function estimate(data, slugs) {
|
|
var bySlug = {};
|
|
data.apps.forEach(function (a) { bySlug[a.slug] = a; });
|
|
var typical = data.platform_mb, peak = data.platform_mb, busy = [], unmeasured = [];
|
|
slugs.forEach(function (s) {
|
|
var a = bySlug[s];
|
|
if (!a || a.builtin) { return; }
|
|
typical += a.typical_mb;
|
|
peak += a.peak_mb;
|
|
if (a.cpu_class === 'medium' || a.cpu_class === 'heavy-in-bursts') { busy.push(a); }
|
|
if (!a.measured) { unmeasured.push(a); }
|
|
});
|
|
var boxes = data.boxes_gb.map(function (gb) {
|
|
var guest = guestMib(data, gb);
|
|
var verdict = peak * data.headroom <= guest ? 'well' : (peak <= guest ? 'tight' : 'no');
|
|
return { gb: gb, guest_mb: guest, verdict: verdict };
|
|
});
|
|
var recommend = null, tightBox = null;
|
|
boxes.forEach(function (b) {
|
|
if (recommend === null && b.verdict === 'well') { recommend = b.gb; }
|
|
if (tightBox === null && b.verdict === 'tight') { tightBox = b.gb; }
|
|
});
|
|
var heavy = busy.filter(function (a) { return a.cpu_class === 'heavy-in-bursts'; });
|
|
var wellGb = minBoxGb(data, peak * data.headroom);
|
|
var barGb = Math.max(wellGb, data.boxes_gb[0]);
|
|
var boxMb = barGb * 1024, guestMb = guestMib(data, barGb);
|
|
var apps = slugs.map(function (s) { return bySlug[s]; })
|
|
.filter(function (a) { return a && !a.builtin; })
|
|
.sort(function (x, y) { return y.peak_mb - x.peak_mb; });
|
|
var segments = [{ kind: 'base', mb: boxMb - guestMb }, { kind: 'platform', mb: data.platform_mb }];
|
|
apps.forEach(function (a) { segments.push({ kind: 'app', app: a, mb: a.peak_mb }); });
|
|
segments.push({ kind: 'spare', mb: guestMb - peak });
|
|
return {
|
|
typical_mb: typical, peak_mb: peak, boxes: boxes, busy: busy, heavy: heavy, unmeasured: unmeasured,
|
|
well_gb: wellGb, bar_gb: barGb, tight_gb: minBoxGb(data, peak),
|
|
recommend_gb: recommend, tight_box_gb: tightBox,
|
|
segments: segments, base_mb: boxMb - guestMb, apps_peak_mb: peak - data.platform_mb
|
|
};
|
|
}
|
|
|
|
root.FelhomSizing = { estimate: estimate, guestMib: guestMib, minBoxGb: minBoxGb };
|
|
|
|
/* ---------------------------------------------------------------- the page (drawing only) */
|
|
if (typeof document === 'undefined') { return; }
|
|
|
|
function el(tag, cls, txt) {
|
|
var e = document.createElement(tag);
|
|
if (cls) { e.className = cls; }
|
|
if (txt !== undefined) { e.textContent = txt; }
|
|
return e;
|
|
}
|
|
|
|
function icon(name, cls) {
|
|
var ns = 'http://www.w3.org/2000/svg';
|
|
var s = document.createElementNS(ns, 'svg');
|
|
s.setAttribute('class', cls || 'ico');
|
|
s.setAttribute('aria-hidden', 'true');
|
|
var u = document.createElementNS(ns, 'use');
|
|
u.setAttribute('href', '/assets/icons.svg?v=1#' + name);
|
|
s.appendChild(u);
|
|
return s;
|
|
}
|
|
|
|
function start() {
|
|
var host = document.getElementById('sizer');
|
|
var strEl = document.getElementById('sizer-strings');
|
|
if (!host || !strEl) { return; }
|
|
var T = JSON.parse(strEl.textContent);
|
|
var lang = document.documentElement.lang === 'en' ? 'en' : 'hu';
|
|
var dec = function (v) { var s = v.toFixed(1); return lang === 'hu' ? s.replace('.', ',') : s; };
|
|
var gbText = function (mb) { return dec(mb / 1024) + ' GB'; };
|
|
var appsBox = document.getElementById('sizer-apps');
|
|
var meter = document.getElementById('sizer-meter');
|
|
var line = document.getElementById('sizer-line');
|
|
var result = document.getElementById('sizer-result');
|
|
var stack = document.getElementById('sizer-stack');
|
|
var legend = document.getElementById('sizer-legend');
|
|
var why = document.getElementById('sizer-why-line');
|
|
var typicalLine = document.getElementById('sizer-typical');
|
|
// English needs "an 8 GB machine"; the article comes from the page's strings (none in Hungarian)
|
|
var article = function (gb) { return (T.an_before || []).indexOf(String(gb)) !== -1 ? T.an : T.a; };
|
|
var fill = function (tpl, gb) { return tpl.replace('{a}', article(gb) || '').replace('{gb}', gb); };
|
|
|
|
fetch(host.getAttribute('data-src')).then(function (r) { return r.json(); }).then(function (data) {
|
|
var max = data.bar_max_gb;
|
|
meter.setAttribute('aria-valuemax', String(max));
|
|
var groups = [];
|
|
|
|
// the bar's marks: positions from the data, the last one reads "64+"
|
|
var scale = document.getElementById('sizer-scale');
|
|
data.bar_marks_gb.forEach(function (gb, i) {
|
|
var m = el('span', 'sizer-mark', gb + (i === data.bar_marks_gb.length - 1 ? '+' : '') + ' GB');
|
|
m.style.left = (gb / max * 100) + '%';
|
|
scale.appendChild(m);
|
|
});
|
|
|
|
// one fold-out per category, all closed at first
|
|
data.categories.forEach(function (c) {
|
|
var d = el('details', 'sizer-cat');
|
|
var sum = el('summary', 'sizer-cat-head');
|
|
sum.appendChild(el('span', 'sizer-cat-name', lang === 'en' ? c.name_en : c.name_hu));
|
|
var count = el('span', 'sizer-cat-count');
|
|
sum.appendChild(count);
|
|
sum.appendChild(icon('i-chevron-down', 'ico sizer-chevron'));
|
|
d.appendChild(sum);
|
|
var body = el('div', 'sizer-cat-body');
|
|
data.apps.filter(function (a) { return a.category === c.id; }).forEach(function (a) {
|
|
var lab = el('label', 'sizer-app');
|
|
var cb = el('input');
|
|
cb.type = 'checkbox';
|
|
cb.value = a.slug;
|
|
if (a.builtin) { cb.checked = true; cb.disabled = true; }
|
|
lab.appendChild(cb);
|
|
lab.appendChild(el('span', 'sizer-app-name', lang === 'en' ? a.name_en : a.name_hu));
|
|
var tag = a.builtin ? T.builtin : (lang === 'en' ? a.tagline_en : a.tagline_hu);
|
|
if (tag) { lab.appendChild(el('span', 'sizer-app-tag', tag)); }
|
|
body.appendChild(lab);
|
|
});
|
|
d.appendChild(body);
|
|
appsBox.appendChild(d);
|
|
groups.push({ details: d, count: count });
|
|
});
|
|
|
|
function selected() {
|
|
return Array.prototype.map.call(appsBox.querySelectorAll('input[type=checkbox]:checked:not(:disabled)'),
|
|
function (c) { return c.value; });
|
|
}
|
|
|
|
function draw() {
|
|
var r = estimate(data, selected());
|
|
|
|
groups.forEach(function (g) {
|
|
var n = g.details.querySelectorAll('input[type=checkbox]:checked:not(:disabled)').length;
|
|
g.count.textContent = n ? T.selected.replace('{n}', n) : '';
|
|
});
|
|
|
|
// the bar: its length is the "fits well" box memory, split into what that memory is used for
|
|
// (estimate().segments); beyond the colour count the smallest apps join one segment
|
|
var basis = Math.max(r.bar_gb, max) * 1024;
|
|
var segs = [], rest = { kind: 'more', mb: 0, n: 0 }, nApps = 0;
|
|
var appCount = r.segments.filter(function (sg) { return sg.kind === 'app'; }).length;
|
|
// group the smallest only when TWO or more are left over; a single leftover keeps its name
|
|
// (in the group's colour — no ninth palette colour)
|
|
var group = appCount - data.bar_app_colours >= 2;
|
|
r.segments.forEach(function (sg) {
|
|
if (sg.kind === 'app') {
|
|
nApps += 1;
|
|
if (nApps > data.bar_app_colours) {
|
|
if (group) { rest.mb += sg.mb; rest.n += 1; return; }
|
|
sg.colour = 'sizer-seg-more';
|
|
} else {
|
|
sg.colour = 'sizer-c' + nApps;
|
|
}
|
|
}
|
|
if (sg.kind === 'spare' && rest.n) { segs.push(rest); }
|
|
segs.push(sg);
|
|
});
|
|
var label = function (sg) {
|
|
if (sg.kind === 'app') { return lang === 'en' ? sg.app.name_en : sg.app.name_hu; }
|
|
if (sg.kind === 'more') { return T.seg_more.replace('{n}', sg.n); }
|
|
return T['seg_' + sg.kind];
|
|
};
|
|
stack.textContent = '';
|
|
legend.textContent = '';
|
|
segs.forEach(function (sg) {
|
|
var cls = sg.kind === 'app' ? sg.colour : 'sizer-seg-' + sg.kind;
|
|
var part = el('div', 'sizer-seg ' + cls);
|
|
part.style.width = (sg.mb / basis * 100) + '%';
|
|
part.title = label(sg) + ': ' + Math.round(sg.mb) + ' MB';
|
|
stack.appendChild(part);
|
|
var li = el('li', 'sizer-legend-item');
|
|
li.appendChild(el('span', 'sizer-dot ' + cls));
|
|
li.appendChild(el('span', '', label(sg) + ' '));
|
|
li.appendChild(el('span', 'sizer-num', gbText(sg.mb)));
|
|
legend.appendChild(li);
|
|
});
|
|
var tli = el('li', 'sizer-legend-item');
|
|
tli.appendChild(el('span', 'sizer-dot sizer-seg-tightmark'));
|
|
tli.appendChild(el('span', '', T.seg_tight + ' '));
|
|
tli.appendChild(el('span', 'sizer-num', dec(r.tight_gb) + ' GB'));
|
|
legend.appendChild(tli);
|
|
document.getElementById('sizer-tight').style.left = (Math.min(r.tight_gb / max, 1) * 100) + '%';
|
|
var over = r.well_gb > max;
|
|
var needText = over ? T.over_max.replace('{gb}', max) : dec(r.well_gb) + ' GB';
|
|
meter.setAttribute('aria-valuenow', String(Math.min(r.well_gb, max).toFixed(1)));
|
|
meter.setAttribute('aria-valuetext', T.meter_text.replace('{x}', needText));
|
|
|
|
// one line: which box to buy, and where it is tight
|
|
var biggest = data.boxes_gb[data.boxes_gb.length - 1];
|
|
if (r.recommend_gb !== null) {
|
|
line.textContent = fill(T.recommend, r.recommend_gb) +
|
|
(r.tight_box_gb !== null ? ' ' + fill(T.tight_on, r.tight_box_gb) : '');
|
|
} else if (r.tight_box_gb !== null) {
|
|
line.textContent = fill(T.only_tight, r.tight_box_gb);
|
|
} else {
|
|
line.textContent = fill(T.none_fits, biggest);
|
|
}
|
|
|
|
// under the bar: totals, why tight is tight, the CPU lines
|
|
result.textContent = '';
|
|
// the explaining sentence: three numbers, all from the rule; bold parts are {b}…{/b}
|
|
why.textContent = '';
|
|
var tpl = r.apps_peak_mb > 0
|
|
? (r.recommend_gb !== null ? T.why : T.why_over)
|
|
: T.why_empty;
|
|
var spare = Math.round((data.headroom - 1) * 100);
|
|
tpl.replace('{peak}', dec(r.peak_mb / 1024)).replace('{base}', dec(r.base_mb / 1024))
|
|
.replace('{base_platform}', dec((r.base_mb + data.platform_mb) / 1024))
|
|
.replace('{spare}', spare).replace('{rec}', r.recommend_gb === null ? max : r.recommend_gb)
|
|
.split(/\{b\}|\{\/b\}/).forEach(function (part, i) {
|
|
why.appendChild(i % 2 ? el('strong', 'sizer-num', part) : document.createTextNode(part));
|
|
});
|
|
typicalLine.textContent = T.typical_line.replace('{typical}', gbText(r.typical_mb)).replace('{peak}', gbText(r.peak_mb));
|
|
if (r.tight_box_gb !== null) { result.appendChild(el('p', 'sizer-why', T.tight_why)); }
|
|
|
|
if (r.busy.length) {
|
|
result.appendChild(el('h3', 'sizer-sub', T.cpu_head));
|
|
var cl = el('ul', 'sizer-cpu');
|
|
r.busy.forEach(function (a) {
|
|
var li = el('li');
|
|
li.appendChild(el('strong', '', (lang === 'en' ? a.name_en : a.name_hu) + ': '));
|
|
li.appendChild(el('span', '', lang === 'en' ? a.cpu_note_en : a.cpu_note_hu));
|
|
cl.appendChild(li);
|
|
});
|
|
if (r.heavy.length >= 2) {
|
|
cl.appendChild(el('li', 'sizer-pair', T.heavy_pair.replace('{names}', r.heavy.map(function (a) {
|
|
return lang === 'en' ? a.name_en : a.name_hu;
|
|
}).join(T.and))));
|
|
}
|
|
result.appendChild(cl);
|
|
}
|
|
if (r.unmeasured.length) {
|
|
result.appendChild(el('p', 'sizer-why', T.unmeasured + ': ' + r.unmeasured.map(function (a) {
|
|
return lang === 'en' ? a.name_en : a.name_hu;
|
|
}).join(', ')));
|
|
}
|
|
}
|
|
|
|
appsBox.addEventListener('change', draw);
|
|
Array.prototype.forEach.call(document.querySelectorAll('[data-preset]'), function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
var want = (btn.getAttribute('data-preset') || '').split(',').filter(Boolean);
|
|
groups.forEach(function (g) {
|
|
var any = false;
|
|
Array.prototype.forEach.call(g.details.querySelectorAll('input[type=checkbox]:not(:disabled)'), function (c) {
|
|
c.checked = want.indexOf(c.value) !== -1;
|
|
if (c.checked) { any = true; }
|
|
});
|
|
if (any) { g.details.open = true; }
|
|
});
|
|
draw();
|
|
});
|
|
});
|
|
host.hidden = false;
|
|
draw();
|
|
}).catch(function () {
|
|
line.textContent = T.load_error;
|
|
host.hidden = false;
|
|
});
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', start);
|
|
} else {
|
|
start();
|
|
}
|
|
})(this);
|