homepage/public/assets/zapret-moe/home.js
2026-10-15 15:07:35 +03:00

2105 lines
131 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Главная git.zapret.moe: заставка-презентация, музыка к ней и «Пульс разработки».
Всё здесь — необязательное улучшение: без JS страница полностью работает. */
(function () {
'use strict';
var root = document.documentElement;
var DAY = 86400;
var MONTHS = ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'];
var COLORS = { gui: '#4fd1ff', zsg: '#9b7bff', kvn: '#3b82ff', magisk: '#ff6bb3', vpn: '#37e39a', other: '#7b89b3', all: '#4fd1ff' };
function fmt(n) { return Math.round(n).toLocaleString('ru-RU') }
function short(n) {
if (n >= 1e6) return (n / 1e6).toLocaleString('ru-RU', { maximumFractionDigits: 1 }) + ' млн';
if (n >= 1e4) return Math.round(n / 1e3).toLocaleString('ru-RU') + ' тыс.';
return fmt(n);
}
function plural(n, one, few, many) {
var m10 = n % 10, m100 = n % 100;
if (m10 === 1 && m100 !== 11) return one;
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few;
return many;
}
function ease(t) { return 1 - Math.pow(1 - t, 3) }
function countUp(el, to, ms) {
var t0 = performance.now();
(function step(now) {
var k = Math.min(1, (now - t0) / ms);
el.textContent = fmt(to * (1 - Math.pow(2, -10 * k)) / (1 - Math.pow(2, -10)));
if (k < 1) requestAnimationFrame(step); else el.textContent = fmt(to);
})(t0);
}
function weekLabel(ts) {
var a = new Date(ts * 1000), b = new Date((ts + 6 * DAY) * 1000);
return a.getUTCDate() + (a.getUTCMonth() === b.getUTCMonth() ? '' : ' ' + MONTHS[a.getUTCMonth()]) +
'–' + b.getUTCDate() + ' ' + MONTHS[b.getUTCMonth()];
}
/* ── Данные: готовый JSON с сервера, запасной вариант — heatmap API ───── */
var pulse = document.getElementById('zpm-pulse');
var statsPromise = null;
function loadStats() {
if (statsPromise) return statsPromise;
if (!pulse || !window.fetch) return (statsPromise = Promise.reject());
var bust = Math.floor(Date.now() / 1800000);
statsPromise = fetch(pulse.getAttribute('data-stats') + '?h=' + bust)
.then(function (r) { if (!r.ok) throw 0; return r.json() })
.then(function (d) { d.source = 'commits'; return d })
.catch(function () {
return fetch(pulse.getAttribute('data-heatmap')).then(function (r) { return r.json() }).then(function (hm) {
var now = Date.now() / 1000, monday = now - (now - 4 * DAY) % (7 * DAY), week0 = monday - 51 * 7 * DAY;
var s = []; for (var i = 0; i < 52; i++) s.push(0);
var t = { commits_year: 0, commits_7d: 0 };
hm.forEach(function (p) {
if (p.timestamp >= week0) s[Math.min(51, Math.floor((p.timestamp - week0) / (7 * DAY)))] += p.contributions;
if (now - p.timestamp < 365 * DAY) t.commits_year += p.contributions;
if (now - p.timestamp < 7 * DAY) t.commits_7d += p.contributions;
});
return { source: 'actions', week0: week0, order: ['all'], labels: { all: 'Все проекты' }, series: { all: s }, totals: t };
});
});
return statsPromise;
}
/* ── Интерактивный график ─────────────────────────────────────────────── */
function Chart(box, data) {
var svg = box.querySelector('svg'), tip = box.querySelector('.zp-tip');
var keys = data.order.filter(function (k) { return data.series[k] && data.series[k].some(Boolean) });
var N = data.series[keys[0]].length;
var on = {}; keys.forEach(function (k) { on[k] = true });
var cur = {}, tgt = {};
keys.forEach(function (k) { cur[k] = data.series[k].map(function () { return 0 }) });
var wCur = 52, wTgt = 52, yCur = 1, reveal = 0, hover = -1, raf = 0;
function targets() {
keys.forEach(function (k) { tgt[k] = data.series[k].map(function (v) { return on[k] ? v : 0 }) });
}
function totalsOf(src, i) { var s = 0; keys.forEach(function (k) { s += src[k][i] }); return s }
function niceMax(v) {
if (v <= 0) return 10;
var p = Math.pow(10, Math.floor(Math.log10(v))), m = v / p;
return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 5 ? 5 : 10) * p;
}
function yTarget(w) {
var m = 0;
for (var i = Math.max(0, N - Math.ceil(w)); i < N; i++) m = Math.max(m, totalsOf(tgt, i));
return niceMax(m * 1.08);
}
function draw() {
var W = box.clientWidth, H = box.clientHeight;
if (!W || !H) return;
var pl = 44, pr = 14, pt = 22, pb = 30, pw = W - pl - pr, ph = H - pt - pb;
var s0 = N - wCur;
var x = function (i) { return pl + (i - s0) / (wCur - 1) * pw };
var y = function (v) { return pt + ph - v / yCur * ph };
var out = '<defs><clipPath id="zp-clip"><rect x="' + pl + '" y="0" width="' + Math.max(0, pw * reveal).toFixed(1) + '" height="' + H + '"/></clipPath>';
keys.forEach(function (k) {
out += '<linearGradient id="zp-g-' + k + '" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="' + COLORS[k] + '" stop-opacity=".85"/><stop offset="1" stop-color="' + COLORS[k] + '" stop-opacity=".25"/></linearGradient>';
});
out += '</defs>';
// сетка и подписи
for (var g = 0; g <= 4; g++) {
var gv = yCur * g / 4, gy = y(gv).toFixed(1);
out += '<line class="zp-gl" x1="' + pl + '" x2="' + (W - pr) + '" y1="' + gy + '" y2="' + gy + '"/>';
out += '<text class="zp-yl" x="' + (pl - 8) + '" y="' + (+gy + 4) + '">' + short(gv) + '</text>';
}
var lastX = -99;
for (var i = Math.max(0, Math.floor(s0)); i < N; i++) {
var t = data.week0 + i * 7 * DAY, d = new Date(t * 1000), prev = new Date((t - 7 * DAY) * 1000);
if (d.getUTCMonth() !== prev.getUTCMonth()) {
var mx = x(i);
if (mx >= pl && mx - lastX > 40) { out += '<text class="zp-xl" x="' + mx.toFixed(1) + '" y="' + (H - 8) + '">' + MONTHS[d.getUTCMonth()] + '</text>'; lastX = mx }
}
}
// слои
out += '<g clip-path="url(#zp-clip)">';
var base = []; for (i = 0; i < N; i++) base.push(0);
var i0 = Math.max(0, Math.floor(s0) - 1);
keys.forEach(function (k) {
var top = [], j;
for (j = 0; j < N; j++) top.push(base[j] + cur[k][j]);
var up = '', down = '';
for (j = i0; j < N; j++) {
var px = x(j).toFixed(1), py = y(top[j]).toFixed(1);
if (j === i0) up = 'M' + px + ' ' + py;
else { var mx2 = ((x(j - 1) + x(j)) / 2).toFixed(1); up += ' C' + mx2 + ' ' + y(top[j - 1]).toFixed(1) + ' ' + mx2 + ' ' + py + ' ' + px + ' ' + py }
}
for (j = N - 1; j >= i0; j--) {
var bx = x(j).toFixed(1), by = y(base[j]).toFixed(1);
if (j === N - 1) down = ' L' + bx + ' ' + by;
else { var mx3 = ((x(j + 1) + x(j)) / 2).toFixed(1); down += ' C' + mx3 + ' ' + y(base[j + 1]).toFixed(1) + ' ' + mx3 + ' ' + by + ' ' + bx + ' ' + by }
}
out += '<path class="zp-ar" fill="url(#zp-g-' + k + ')" d="' + up + down + ' Z"/>';
out += '<path class="zp-ln" stroke="' + COLORS[k] + '" d="' + up + '"/>';
base = top;
});
out += '</g>';
// рекордная неделя
var best = -1, bestV = 0;
for (i = Math.max(0, Math.ceil(s0)); i < N; i++) { var tv = totalsOf(tgt, i); if (tv > bestV) { bestV = tv; best = i } }
if (best >= 0 && reveal > 0.98 && hover < 0) {
var bx2 = x(best), by2 = y(base[best]);
out += '<g class="zp-best"><circle cx="' + bx2.toFixed(1) + '" cy="' + by2.toFixed(1) + '" r="5"/><text x="' + Math.min(W - pr - 50, Math.max(pl + 50, bx2)).toFixed(1) + '" y="' + Math.max(14, by2 - 12).toFixed(1) + '">рекорд · ' + fmt(bestV) + '</text></g>';
}
// курсор
if (hover >= 0) {
var hx = x(hover).toFixed(1);
out += '<line class="zp-cur" x1="' + hx + '" x2="' + hx + '" y1="' + pt + '" y2="' + (pt + ph) + '"/>';
var acc = 0;
keys.forEach(function (k) { if (!on[k]) return; acc += cur[k][hover]; out += '<circle class="zp-dot" cx="' + hx + '" cy="' + y(acc).toFixed(1) + '" r="4" stroke="' + COLORS[k] + '"/>' });
}
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
svg.innerHTML = out;
svg.__x = x; svg.__s0 = s0; svg.__pl = pl; svg.__pw = pw;
}
function animate(ms, withReveal) {
cancelAnimationFrame(raf);
var from = {}, w0 = wCur, y0 = yCur, y1 = yTarget(wTgt), r0 = reveal, t0 = performance.now();
keys.forEach(function (k) { from[k] = cur[k].slice() });
(function step(now) {
var k = Math.min(1, (now - t0) / ms), e = ease(k);
keys.forEach(function (key) { for (var i = 0; i < N; i++) cur[key][i] = from[key][i] + (tgt[key][i] - from[key][i]) * e });
wCur = w0 + (wTgt - w0) * e; yCur = y0 + (y1 - y0) * e;
if (withReveal) reveal = r0 + (1 - r0) * ease(Math.min(1, (now - t0) / (ms * 0.9)));
draw();
if (k < 1) raf = requestAnimationFrame(step);
})(t0);
}
// подсказка
function showTip(ev) {
if (!svg.__x) return;
var r = svg.getBoundingClientRect(), px = ev.clientX - r.left;
var i = Math.round(svg.__s0 + (px - svg.__pl) / svg.__pw * (wCur - 1));
i = Math.max(Math.ceil(svg.__s0), Math.min(N - 1, i));
if (i !== hover) { hover = i; draw() }
var rows = keys.filter(function (k) { return on[k] && data.series[k][i] > 0 })
.sort(function (a, b) { return data.series[b][i] - data.series[a][i] });
var tot = 0; rows.forEach(function (k) { tot += data.series[k][i] });
var word = data.source === 'commits' ? plural(tot, 'коммит', 'коммита', 'коммитов') : plural(tot, 'действие', 'действия', 'действий');
tip.innerHTML = '<h4>' + weekLabel(data.week0 + i * 7 * DAY) + '</h4><p class="zp-tt">' + fmt(tot) + ' <small>' + word + '</small></p>' +
(rows.length > 1 ? '<ul>' + rows.map(function (k) { return '<li><span><i style="--c:' + COLORS[k] + '"></i>' + data.labels[k] + '</span><b>' + fmt(data.series[k][i]) + '</b></li>' }).join('') + '</ul>' : '');
tip.hidden = false;
var tx = svg.__x(i), tw = tip.offsetWidth;
tip.style.left = Math.max(tw / 2 + 4, Math.min(box.clientWidth - tw / 2 - 4, tx)) + 'px';
}
function hideTip() { hover = -1; tip.hidden = true; draw() }
svg.addEventListener('pointermove', showTip);
svg.addEventListener('pointerdown', showTip);
svg.addEventListener('pointerleave', hideTip);
// легенда
var legend = document.getElementById('zp-legend');
if (keys.length > 1) keys.forEach(function (k) {
var b = document.createElement('button');
var sum = data.series[k].reduce(function (a, v) { return a + v }, 0);
b.type = 'button'; b.className = 'zp-lg'; b.style.setProperty('--c', COLORS[k]);
b.setAttribute('aria-pressed', 'true');
b.innerHTML = '<i></i>' + data.labels[k] + ' <small>' + short(sum) + '</small>';
b.addEventListener('click', function () {
var active = keys.filter(function (q) { return on[q] });
if (on[k] && active.length === 1) { keys.forEach(function (q) { on[q] = true }) } // последний — включить всё
else on[k] = !on[k];
legend.querySelectorAll('.zp-lg').forEach(function (el, idx) { el.setAttribute('aria-pressed', on[keys[idx]] ? 'true' : 'false') });
targets(); animate(650);
});
legend.appendChild(b);
});
// период
pulse.querySelectorAll('.zp-range button').forEach(function (b) {
b.addEventListener('click', function () {
pulse.querySelectorAll('.zp-range button').forEach(function (o) { o.setAttribute('aria-pressed', o === b ? 'true' : 'false') });
wTgt = +b.getAttribute('data-w'); animate(750);
});
});
targets();
yCur = yTarget(52);
if (window.ResizeObserver) new ResizeObserver(function () { draw() }).observe(box);
this.play = function () { animate(1400, true) };
draw();
}
if (pulse) loadStats().then(function (data) {
var totals = data.totals || {};
pulse.querySelectorAll('dd[data-k]').forEach(function (el) {
if (totals[el.getAttribute('data-k')] == null) el.closest('.zp-kpi').hidden = true;
});
if (data.source !== 'commits') {
var main = pulse.querySelector('.zp-kpi-main dt'); if (main) main.textContent = 'действий в git за год';
}
pulse.hidden = false;
var chart = new Chart(document.getElementById('zp-chart'), data);
var started = false;
function go() {
if (started) return; started = true;
pulse.querySelectorAll('[data-k]').forEach(function (el) {
var v = totals[el.getAttribute('data-k')]; if (v != null) countUp(el, v, 1600);
});
chart.play();
}
if (window.IntersectionObserver) {
var io = new IntersectionObserver(function (es) { if (es[0].isIntersecting) { io.disconnect(); go() } }, { threshold: 0.25 });
io.observe(pulse);
} else go();
}).catch(function () {});
/* общие функции для заставки (intro.js) */
window.zpm = { loadStats: loadStats, fmt: fmt, short: short, plural: plural, countUp: countUp };
/* ── Дуэль на главной: персонажи выплывают с боков ─────────────────── */
var duel = document.getElementById('zpm-duel');
if (duel && window.IntersectionObserver && !(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches)) {
duel.classList.add('zd-js');
var dio = new IntersectionObserver(function (es) {
if (es[0].isIntersecting) { duel.classList.add('zd-on'); duel._onAt = performance.now(); dio.disconnect() }
}, { threshold: 0.2 });
dio.observe(duel);
}
/* ── Драка в дуэли: хореография на таймлайне ─────────────────────────
Бойцы в полный рост стоят лицом друг к другу (слои: волосы, основа, две руки
с осью в плече). Цикл 7,2 с: РКН-тян бросается и полосует когтями — Запрет-тян
уходит назад (промах, следы когтей); колющий удар — блок рукой (щит, искры);
контратака кулаком — вспышка, ударная волна, РКН-тян отбрасывает; ярость.
Тело двигают свойства translate/rotate/scale (Web Animations), руки — transform:
они складываются с CSS-покачиванием стойки. Точки эффектов берутся с реальных
прямоугольников кулака и когтей в момент удара. */
(function () {
if (!duel || !duel.animate || !window.IntersectionObserver) return;
if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var zt = duel.querySelector('.zf-zt'), rk = duel.querySelector('.zf-rkn');
if (!zt || !rk) return;
var ztGuard = zt.querySelector('.zf-arm0'), ztPunch = zt.querySelector('.zf-arm1');
var rkClaw = rk.querySelector('.zf-arm0'), rkBack = rk.querySelector('.zf-arm1');
var vs = duel.querySelector('.zd-vs span');
duel.classList.add('zd-fight');
var cv = document.createElement('canvas');
cv.className = 'zd-fx'; cv.setAttribute('aria-hidden', 'true');
duel.appendChild(cv);
var g = cv.getContext('2d'), W = 0, H = 0, dpr = 1, fx = [], raf = 0;
function size() {
var r = duel.getBoundingClientRect();
dpr = Math.min(2, window.devicePixelRatio || 1); W = r.width; H = r.height;
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
}
addEventListener('resize', size);
// точка на элементе (доли его прямоугольника) в координатах дуэли
function at(el, fx_, fy) {
var d = duel.getBoundingClientRect(), r = el.getBoundingClientRect();
return { x: r.left - d.left + r.width * fx_, y: r.top - d.top + r.height * fy };
}
var OUT = 'cubic-bezier(0.2, 0.8, 0.3, 1)', IN = 'cubic-bezier(0.6, 0, 0.9, 0.5)', IO = 'cubic-bezier(0.4, 0, 0.3, 1)';
// тело: [доля, сдвиг X в % своей ширины, сдвиг Y в %, наклон°, изинг]
function body(el, keys, ms) {
el.animate(keys.map(function (k) {
var f = { offset: k[0], translate: k[1] + '% ' + (k[2] || 0) + '%', rotate: (k[3] || 0) + 'deg' };
if (k[4]) f.easing = k[4];
return f;
}), { duration: ms, easing: IO });
}
// рука: [доля, поворот°, масштаб, изинг]
function arm(el, keys, ms) {
if (!el) return;
el.animate(keys.map(function (k) {
var f = { offset: k[0], transform: 'rotate(' + k[1] + 'deg) scale(' + (k[2] || 1) + ')' };
if (k[3]) f.easing = k[3];
return f;
}), { duration: ms, easing: IO });
}
function shake(power) {
duel.animate([{ translate: '0 0' }, { translate: -power + 'px ' + power / 2 + 'px' }, { translate: power + 'px ' + -power / 2 + 'px' },
{ translate: -power / 2 + 'px ' + power / 4 + 'px' }, { translate: '0 0' }], { duration: 280 });
}
function flashVs(k) {
if (vs) vs.animate([{ filter: 'drop-shadow(0 0 18px rgba(155,123,255,.8))' }, { filter: 'drop-shadow(0 0 40px #fff) brightness(' + k + ')' },
{ filter: 'drop-shadow(0 0 18px rgba(155,123,255,.8))' }], { duration: 420 });
}
function mark(el, cls, ms) { el.classList.add(cls); setTimeout(function () { el.classList.remove(cls) }, ms) }
/* ── эффекты на холсте ── */
function add(o) { o.t0 = performance.now(); fx.push(o); if (!raf) raf = requestAnimationFrame(draw) }
function sparks(p, n, speed, colors, up) {
for (var i = 0; i < n; i++) {
var a = Math.random() * Math.PI * 2, v = speed * (0.35 + Math.random() * 0.8);
add({ k: 'spark', x: p.x, y: p.y, vx: Math.cos(a) * v, vy: Math.sin(a) * v - (up || 0), life: 380 + Math.random() * 380, c: colors[i % colors.length] });
}
}
function draw(now) {
raf = 0;
g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, W, H);
g.lineCap = 'round'; g.globalCompositeOperation = 'lighter';
var U = Math.min(W, H);
fx = fx.filter(function (o) {
var t = now - o.t0;
if (o.k === 'slash') { // три следа когтей: прорисовываются за 110 мс и гаснут
var p = Math.min(1, t / 110), a = t < 110 ? 1 : Math.max(0, 1 - (t - 110) / 420);
for (var i = -1; i <= 1; i++) {
var ox = -o.ny * i * o.gap, oy = o.nx * i * o.gap;
var sx = o.x - o.nx * o.len / 2 + ox, sy = o.y - o.ny * o.len / 2 + oy;
var ex = sx + o.nx * o.len * p, ey = sy + o.ny * o.len * p, cx = (sx + ex) / 2 - o.ny * o.len * 0.12, cy = (sy + ey) / 2 + o.nx * o.len * 0.12;
[[16, 'rgba(255,40,80,' + 0.18 * a + ')'], [7, 'rgba(255,70,100,' + 0.55 * a + ')'], [2.2, 'rgba(255,235,240,' + a + ')']].forEach(function (s) {
g.beginPath(); g.moveTo(sx, sy); g.quadraticCurveTo(cx, cy, ex, ey); g.lineWidth = s[0]; g.strokeStyle = s[1]; g.stroke();
});
}
return t < 530;
}
if (o.k === 'shield') { // блок: голубой шестигранник щита
var q = Math.min(1, t / 460), r = o.r * (0.85 + 0.3 * q), al = 1 - q;
[[18, 'rgba(79,209,255,' + 0.16 * al + ')'], [6, 'rgba(79,209,255,' + 0.55 * al + ')'], [2, 'rgba(230,250,255,' + al + ')']].forEach(function (s) {
g.beginPath();
for (var j = 0; j <= 6; j++) { var an = j / 6 * Math.PI * 2 + Math.PI / 6; g[j ? 'lineTo' : 'moveTo'](o.x + Math.cos(an) * r, o.y + Math.sin(an) * r) }
g.lineWidth = s[0]; g.strokeStyle = s[1]; g.stroke();
});
return t < 460;
}
if (o.k === 'burst') { // попадание: вспышка, кольца и лучи
var b = Math.min(1, t / 480), fade = 1 - b;
if (t < 200) {
var gr = g.createRadialGradient(o.x, o.y, 0, o.x, o.y, Math.max(W, H) * 0.6);
gr.addColorStop(0, 'rgba(235,248,255,' + 0.5 * (1 - t / 200) + ')'); gr.addColorStop(1, 'rgba(79,209,255,0)');
g.fillStyle = gr; g.fillRect(0, 0, W, H);
}
var R = U * 0.3 * (0.2 + 1.3 * Math.pow(b, 0.6));
g.beginPath(); g.arc(o.x, o.y, R, 0, Math.PI * 2); g.lineWidth = 3 + 8 * fade; g.strokeStyle = 'rgba(200,240,255,' + 0.85 * fade + ')'; g.stroke();
g.beginPath(); g.arc(o.x, o.y, R * 0.7, 0, Math.PI * 2); g.lineWidth = 10 * fade; g.strokeStyle = 'rgba(255,59,92,' + 0.35 * fade + ')'; g.stroke();
for (var k = 0; k < 14; k++) {
var an2 = k / 14 * Math.PI * 2 + o.rot, r0 = R * 0.35, r1 = R * (0.9 + 0.5 * ((k * 7) % 5) / 5);
g.beginPath(); g.moveTo(o.x + Math.cos(an2) * r0, o.y + Math.sin(an2) * r0); g.lineTo(o.x + Math.cos(an2) * r1, o.y + Math.sin(an2) * r1);
g.lineWidth = 2.4 * fade + 0.5; g.strokeStyle = 'rgba(230,250,255,' + fade + ')'; g.stroke();
}
return t < 480;
}
if (o.k === 'spark') {
var s2 = t / 1000, life = 1 - t / o.life, x = o.x + o.vx * s2, y = o.y + o.vy * s2 + 900 * s2 * s2;
g.beginPath(); g.moveTo(x, y); g.lineTo(x - o.vx * 0.03, y - (o.vy + 1800 * s2) * 0.03);
g.lineWidth = 2; g.strokeStyle = o.c.replace('A', Math.max(0, life).toFixed(2)); g.stroke();
return t < o.life;
}
return false;
});
g.globalCompositeOperation = 'source-over';
if (fx.length) raf = requestAnimationFrame(draw);
else g.clearRect(0, 0, W, H);
}
var BLUE = ['rgba(79,209,255,A)', 'rgba(230,250,255,A)'], RED = ['rgba(255,59,92,A)', 'rgba(255,200,210,A)'];
// кончики: когти РКН-тян — левый край её передней руки, кулак Запрет-тян — правый край её ударной
function claw() { return at(rkClaw, 0.08, 0.5) }
function fist() { return at(ztPunch, 0.93, 0.62) }
/* ── хореография ── */
var P = 7200;
var EV = [
[150, function () { // РКН-тян: замах
body(rk, [[0, 0, 0, 0], [1, 3, 0, 3]], 450);
arm(rkClaw, [[0, 0], [1, 24, 1.04, IN]], 450);
}],
[600, function () { // бросок и удар когтями сверху вниз
body(rk, [[0, 3, 0, 3], [0.2, -11, 1, -5, OUT], [0.55, -9, 1, -4], [1, 0, 0, 0]], 1000);
arm(rkClaw, [[0, 24, 1.04], [0.16, -30, 1.12, OUT], [0.5, -22, 1.06], [1, 0, 1]], 1000);
}],
[620, function () { body(zt, [[0, 0, 0, 0], [0.3, -8, -1, -9, OUT], [0.65, -7, -1, -7], [1, 0, 0, 0]], 850) }], // Запрет-тян уклоняется
[760, function () { // промах: следы когтей в воздухе
var p = claw(), ang = 1.15;
add({ k: 'slash', x: p.x - W * 0.02, y: p.y + H * 0.04, nx: Math.cos(ang), ny: Math.sin(ang), len: H * 0.42, gap: H * 0.04 });
shake(3);
}],
[2200, function () { // колющий удар и блок
body(rk, [[0, 0, 0, 0], [0.35, 2, 0, 2], [0.55, -9, 0, -3, OUT], [1, 0, 0, 0]], 1100);
arm(rkClaw, [[0, 0, 1], [0.35, 8, 0.96], [0.55, -4, 1.22, OUT], [0.8, -2, 1.14], [1, 0, 1]], 1100);
arm(ztGuard, [[0, 0, 1], [0.4, -16, 1.06, OUT], [0.75, -14, 1.05], [1, 0, 1]], 1100);
body(zt, [[0, 0, 0, 0], [0.55, 0, 0, 0], [0.7, -3, 0, -3, OUT], [1, 0, 0, 0]], 1100);
}],
[2810, function () {
var a = claw(), b = at(ztGuard, 0.85, 0.2), p = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
add({ k: 'shield', x: p.x, y: p.y, r: H * 0.13 });
sparks(p, 18, 520, BLUE.concat(RED), 120); shake(4); flashVs(1.5);
}],
[3500, function () { // контратака кулаком
body(zt, [[0, 0, 0, 0], [0.25, -3, 0, -3, IN], [0.42, 22, -1, 6, OUT], [0.62, 18, -1, 5], [1, 0, 0, 0]], 1300);
arm(ztPunch, [[0, 0, 1], [0.25, 10, 0.94, IN], [0.42, -5, 1.38, OUT], [0.62, -4, 1.3], [1, 0, 1]], 1300);
arm(ztGuard, [[0, 0, 1], [0.42, -8, 1.04], [1, 0, 1]], 1300);
}],
[4060, function () { // попадание
var p = fist();
add({ k: 'burst', x: p.x, y: p.y, rot: Math.random() });
sparks(p, 34, 900, BLUE, 200); sparks(p, 10, 600, RED, 100);
shake(10); flashVs(2.2); duel._boostUntil = performance.now() + 520;
body(rk, [[0, 0, 0, 0], [0.12, 10, -1, 9, OUT], [0.45, 7, 0, 6], [1, 0, 0, 0]], 1200);
arm(rkClaw, [[0, 0, 1], [0.15, 32, 1.05, OUT], [0.5, 18, 1.02], [1, 0, 1]], 1200);
arm(rkBack, [[0, 0, 1], [0.15, -18, 1.04, OUT], [1, 0, 1]], 1200);
mark(rk, 'zf-hit', 520);
}],
[5400, function () { // ярость РКН-тян, Запрет-тян дразнится
arm(rkClaw, [[0, 0, 1], [0.25, 26, 1.1, OUT], [0.4, 20, 1.06], [0.55, 26, 1.1], [1, 0, 1]], 1300);
arm(rkBack, [[0, 0, 1], [0.25, -24, 1.08, OUT], [0.4, -18, 1.04], [0.55, -24, 1.08], [1, 0, 1]], 1300);
body(rk, [[0, 0, 0, 0], [0.3, 1, -2, -2], [1, 0, 0, 0]], 1300);
body(zt, [[0, 0, 0, 0], [0.3, 0, -4, -2, OUT], [0.5, 0, 0, 0], [0.7, 0, -2, -1], [1, 0, 0, 0]], 1000);
arm(ztGuard, [[0, 0, 1], [0.3, -12, 1.05], [0.6, -4, 1.02], [1, 0, 1]], 1000);
}]
];
var t0 = 0, last = -1, on = false, loop = 0;
function tick(now) {
loop = 0;
if (!on || document.hidden || root.classList.contains('zpm-intro-on')) return;
if (!t0) { t0 = now; last = -1 }
var ct = (now - t0) % P;
if (ct < last) last = -1; // новый круг
EV.forEach(function (e) { if (e[0] > last && e[0] <= ct) e[1]() });
last = ct;
loop = requestAnimationFrame(tick);
}
function start() { if (!loop && on) { size(); t0 = 0; loop = requestAnimationFrame(tick) } }
new IntersectionObserver(function (es) {
on = es[0].isIntersecting && duel.classList.contains('zd-on');
if (on) start(); else if (loop) { cancelAnimationFrame(loop); loop = 0 }
}, { threshold: 0.25 }).observe(duel);
// дуэль включается (zd-on) чуть позже появления на экране — подхватываем после выезда бойцов
var mo = new MutationObserver(function () { if (duel.classList.contains('zd-on') && !on) { on = true; setTimeout(start, 1400); mo.disconnect() } });
mo.observe(duel, { attributes: true, attributeFilter: ['class'] });
document.addEventListener('visibilitychange', function () { if (!document.hidden && on) { t0 = 0; start() } });
})();
/* ── Появление блоков при прокрутке ─────────────────────────────────── */
(function () {
if (!window.IntersectionObserver || (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches)) return;
var groups = [
['.zsec-head', ''], ['.zp-kpi', 'rv-pop'], ['.zp-panel', ''], ['.zc', 'rv-card'],
['.zt-list li', 'rv-side'], ['.zrn', 'rv-side'], ['.zfd', ''], ['.zci-run', ''],
['.zci-points > div', ''], ['.zj-help', 'rv-card'], ['.zj-join > h2', ''], ['.zj-hs li', 'rv-side'], ['.zj-cta', ''],
['.zci-points > .zb', 'rv-pop']
];
var io = new IntersectionObserver(function (es) {
es.forEach(function (e) {
if (!e.isIntersecting) return;
e.target.classList.remove('rv-wait'); e.target.classList.add('rv-in'); io.unobserve(e.target);
});
}, { rootMargin: '0px 0px -8% 0px' });
function words(h) {
var w = 0;
h.innerHTML = h.textContent.split(/(\s+)/).map(function (p) {
return /^\s+$/.test(p) || !p ? p : '<span class="rw" style="--w:' + (w++) + '">' + p + '</span>';
}).join('');
h.classList.add('rv-words');
}
groups.forEach(function (g) {
document.querySelectorAll('.zpm-home ' + g[0]).forEach(function (el) {
if (el.getBoundingClientRect().top < innerHeight) return; // уже на экране — не прячем
var sib = el.parentNode ? Array.prototype.filter.call(el.parentNode.children, function (c) { return c.matches(g[0].split(' ').pop()) }) : [el];
el.style.setProperty('--rv', Math.min(8, sib.indexOf(el)));
if (g[1]) el.classList.add(g[1]);
var h = el.matches('h2') ? el : el.querySelector(':scope > h2');
if (h && !h.querySelector('*')) words(h);
el.classList.add('rv-wait');
io.observe(el);
});
});
})();
/* ── Живые цифры в шапке ───────────────────────────────────────────── */
var hs = document.getElementById('zh-stats');
if (hs) loadStats().then(function (d) {
if (d.source !== 'commits') return;
var t = d.totals || {};
hs.hidden = false;
hs.querySelectorAll('[data-hs]').forEach(function (b) {
var v = t[b.getAttribute('data-hs')];
if (v == null) { b.parentNode.hidden = true; return }
var go = function () { countUp(b, v, 1800) };
if (root.classList.contains('zpm-intro-on')) {
var wait = setInterval(function () { if (!root.classList.contains('zpm-intro-on')) { clearInterval(wait); go() } }, 300);
} else setTimeout(go, 1500);
});
}).catch(function () {});
/* ── Персонажи: следят за курсором и реагируют на касание ────────────── */
(function () {
if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var rigs = Array.prototype.slice.call(document.querySelectorAll('.zrig'));
if (!rigs.length) return;
var mx = innerWidth / 2, my = innerHeight / 2, raf = 0;
var state = rigs.map(function () { return { x: 0, y: 0 } });
function tick() {
raf = 0;
var moving = false;
rigs.forEach(function (r, i) {
var b = rects[i]; // положение кэшировано: чтение rect каждый кадр заставляло браузер пересчитывать раскладку
if (!b || !b.width || b.bottom < 0 || b.top > innerHeight) return;
var tx = Math.max(-1, Math.min(1, (mx - (b.left + b.width / 2)) / (b.width * 0.6)));
var ty = Math.max(-1, Math.min(1, (my - (b.top + b.height / 2)) / (b.height * 0.6)));
var s = state[i];
s.x += (tx - s.x) * 0.08; s.y += (ty - s.y) * 0.08;
if (Math.abs(tx - s.x) > 0.002 || Math.abs(ty - s.y) > 0.002) moving = true;
r.style.setProperty('--px', s.x.toFixed(3));
r.style.setProperty('--py', s.y.toFixed(3));
});
if (moving) raf = requestAnimationFrame(tick);
}
var rects = [], rectsAt = 0;
function measure() { rects = rigs.map(function (r) { return r.getBoundingClientRect() }); rectsAt = performance.now() }
function kick() { if (!raf) { if (performance.now() - rectsAt > 300) measure(); raf = requestAnimationFrame(tick) } }
addEventListener('pointermove', function (e) { mx = e.clientX; my = e.clientY; kick() }, { passive: true });
addEventListener('scroll', function () { rectsAt = 0; kick() }, { passive: true });
addEventListener('resize', function () { rectsAt = 0 });
function poke(r) {
if (r.classList.contains('zr-poke')) return;
r.classList.add('zr-poke');
setTimeout(function () { r.classList.remove('zr-poke') }, 800);
}
rigs.forEach(function (r) {
var last = 0;
r.addEventListener('pointerenter', function () { if (Date.now() - last > 1500) { last = Date.now(); poke(r) } });
r.addEventListener('pointerdown', function () { poke(r) });
});
})();
/* ── Молния дуэли: фрактальный разряд на canvas ─────────────────────── */
(function () {
var duel = document.getElementById('zpm-duel');
var cv = duel && duel.querySelector('.zd-canvas');
if (!cv || !cv.getContext || (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches)) return;
var g = cv.getContext('2d'), W = 0, H = 0, dpr = 1, horiz = false, visible = false, raf = 0, nextShape = 0, bolts = [], flick = 1;
duel.classList.add('zd-live');
function size() {
var r = cv.getBoundingClientRect();
dpr = Math.min(2, window.devicePixelRatio || 1);
W = r.width; H = r.height;
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
horiz = W > H;
}
// смещение средней точки: ломаная с убывающим разбросом
function crack(x1, y1, x2, y2, rough, depth, out) {
if (depth === 0) { out.push([x2, y2]); return }
var mx = (x1 + x2) / 2, my = (y1 + y2) / 2, len = Math.hypot(x2 - x1, y2 - y1);
var nx = -(y2 - y1) / len, ny = (x2 - x1) / len, off = (Math.random() - 0.5) * len * rough;
mx += nx * off; my += ny * off;
crack(x1, y1, mx, my, rough, depth - 1, out);
crack(mx, my, x2, y2, rough, depth - 1, out);
}
function shape(boost) {
var L = horiz ? W : H, T = horiz ? H : W, main = [];
var a = horiz ? [-10, H / 2] : [W / 2, -10], b = horiz ? [W + 10, H / 2] : [W / 2, H + 10];
main.push(a); crack(a[0], a[1], b[0], b[1], 0.32, 8, main);
// держим ствол в пределах полосы
var ax = horiz ? 1 : 0, c = T / 2, dev = 1;
main.forEach(function (p) { dev = Math.max(dev, Math.abs(p[ax] - c)) });
var f = Math.min(1, T * 0.38 / dev); // сжимаем разброс равномерно, а не обрезаем по краю
main.forEach(function (p) { p[ax] = c + (p[ax] - c) * f });
var list = [{ pts: main, w: 1 }];
var nb = (boost ? 7 : 3) + Math.floor(Math.random() * 3);
for (var i = 0; i < nb; i++) {
var s = main[4 + Math.floor(Math.random() * (main.length - 8))];
var ang = (horiz ? Math.PI / 2 : 0) + (Math.random() < 0.5 ? -1 : 1) * (0.5 + Math.random() * 0.9) + (horiz ? 0 : (Math.random() < 0.5 ? 0 : Math.PI));
var bl = L * (0.08 + Math.random() * (boost ? 0.22 : 0.14));
var e = [s[0] + Math.sin(ang) * bl * (horiz ? 1 : 0.9), s[1] + Math.cos(ang) * bl];
if (!horiz) e = [s[0] + (Math.random() < 0.5 ? -1 : 1) * bl * 0.6, s[1] + bl * (Math.random() < 0.3 ? -0.5 : 0.8)];
else e = [s[0] + bl * (Math.random() < 0.5 ? -0.6 : 0.8), s[1] + (Math.random() < 0.5 ? -1 : 1) * bl * 0.6];
var pts = [s]; crack(s[0], s[1], e[0], e[1], 0.45, 5, pts);
list.push({ pts: pts, w: 0.45 });
}
return list;
}
function stroke(pts, width, color, blur) {
g.beginPath(); g.moveTo(pts[0][0], pts[0][1]);
for (var i = 1; i < pts.length; i++) g.lineTo(pts[i][0], pts[i][1]);
g.lineWidth = width; g.strokeStyle = color; g.stroke(); // без shadowBlur: он дорогой, свечение — широкими слоями
}
function frame(now) {
raf = 0;
if (!visible || document.hidden) return;
if (root.classList.contains('zpm-intro-on')) { setTimeout(kick, 500); return }
var boost = false;
if (duel._boostUntil) boost = now < duel._boostUntil; // всплеск разряда в момент удара — его ставит хореография драки
if (now > nextShape) {
bolts = shape(boost);
nextShape = now + (boost ? 45 : 70 + Math.random() * 60);
flick = Math.random() < 0.06 ? 0.25 : 0.75 + Math.random() * 0.25;
}
g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.clearRect(0, 0, W, H);
g.lineCap = 'round'; g.lineJoin = 'round';
g.globalCompositeOperation = 'lighter';
var k = (boost ? 1.9 : 1.2) * flick, sh = horiz ? [0, 2] : [2, 0];
bolts.forEach(function (b) {
var w = b.w * k;
g.save(); g.translate(-sh[0], -sh[1]); stroke(b.pts, 26 * w, 'rgba(79,209,255,' + (0.06 * k) + ')'); stroke(b.pts, 12 * w, 'rgba(79,209,255,' + (0.16 * k) + ')'); g.restore();
g.save(); g.translate(sh[0], sh[1]); stroke(b.pts, 26 * w, 'rgba(255,59,92,' + (0.06 * k) + ')'); stroke(b.pts, 12 * w, 'rgba(255,59,92,' + (0.15 * k) + ')'); g.restore();
stroke(b.pts, 3.2 * w, 'rgba(190,225,255,' + (0.8 * k) + ')', 12);
stroke(b.pts, 1.3 * w + 0.4, 'rgba(255,255,255,' + Math.min(1, k) + ')', 4);
});
g.globalCompositeOperation = 'source-over';
raf = requestAnimationFrame(frame);
}
function kick() { if (!raf && visible) raf = requestAnimationFrame(frame) }
size();
addEventListener('resize', function () { size(); nextShape = 0 });
document.addEventListener('visibilitychange', kick);
new IntersectionObserver(function (es) { visible = es[0].isIntersecting; if (visible) { size(); kick() } }).observe(duel);
})();
/* ── Досмотр на ТСПУ: релизы из API и шум байтов в «том, что видит цензор» ── */
(function () {
var net = document.getElementById('zr-net');
if (!net) return;
var rows = net.querySelectorAll('.zrn[data-repo]');
function ago(ts) {
var d = Math.floor((Date.now() - ts) / 864e5);
if (d < 1) return 'сегодня';
if (d < 2) return 'вчера';
if (d < 14) return d + ' ' + plural(d, 'день', 'дня', 'дней') + ' назад';
if (d < 60) { var w = Math.floor(d / 7); return w + ' ' + plural(w, 'неделю', 'недели', 'недель') + ' назад' }
var m = Math.floor(d / 30); return m + ' ' + plural(m, 'месяц', 'месяца', 'месяцев') + ' назад';
}
function load() {
rows.forEach(function (b) {
fetch(net.getAttribute('data-api') + b.getAttribute('data-repo') + '/releases/latest')
.then(function (r) { return r.ok ? r.json() : null })
.then(function (r) {
if (!r || !r.tag_name) return;
var ts = Date.parse(r.published_at || r.created_at);
var dl = (r.assets || []).reduce(function (a, x) { return a + (x.download_count || 0) }, 0);
b.querySelector('[data-f="ver"]').textContent = 'v' + String(r.tag_name).replace(/^(zsg-|v)/i, '');
if (ts) b.querySelector('[data-f="age"]').textContent = ago(ts);
if (dl) b.querySelector('[data-f="dl"]').textContent = '↓ ' + short(dl);
if (ts && Date.now() - ts < 3 * 864e5) b.classList.add('zrn-fresh');
}).catch(function () {});
});
}
if (!window.IntersectionObserver) { if (window.fetch) load(); return }
if (window.fetch) {
var io = new IntersectionObserver(function (es) { if (es[0].isIntersecting) { io.disconnect(); load() } }, { rootMargin: '600px 0px' });
io.observe(net);
}
/* пока блок на экране, пара байтов в каждой строке меняется — цензор видит только шум */
if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var hex = net.querySelectorAll('[data-hex]'), HEX = '0123456789abcdef', timer = 0;
function noise() {
hex.forEach(function (el) {
var bytes = el.textContent.split(' ');
for (var k = 0; k < 2; k++) bytes[Math.floor(Math.random() * bytes.length)] = HEX[Math.floor(Math.random() * 16)] + HEX[Math.floor(Math.random() * 16)];
el.textContent = bytes.join(' ');
});
}
new IntersectionObserver(function (es) {
clearInterval(timer);
if (es[0].isIntersecting) timer = setInterval(noise, 140);
}).observe(net);
})();
/* ── Лаборатория доверия: сборка по шагам и сверка sha256 ───────────── */
(function () {
var lab = document.getElementById('zci-lab');
if (!lab || !window.IntersectionObserver || (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches)) return;
var items = lab.querySelectorAll('.zci-log li'), codes = lab.querySelectorAll('.zci-h code'), stamp = lab.querySelector('.zci-ok');
var HEX = '0123456789abcdef', visible = false, busy = false, timers = [];
function later(fn, ms) { timers.push(setTimeout(fn, ms)) }
function hex(n) { var s = ''; for (var i = 0; i < n; i++) s += HEX[Math.floor(Math.random() * 16)]; return s }
function run() {
if (busy || !visible) return;
busy = true;
lab.classList.add('zci-live'); lab.classList.remove('zci-match'); stamp.classList.remove('on');
var hash = hex(64);
codes.forEach(function (c) { c.innerHTML = hash.split('').map(function (_, n) { return '<i style="--n:' + n + '">·</i>' }).join('') });
items.forEach(function (li) { li.className = 'pending' });
var t = 300;
items.forEach(function (li, i) {
later(function () { li.className = 'run' }, t);
t += i === 2 ? 1500 : 600 + Math.random() * 300;
later(function () { li.className = 'ok' }, t);
});
later(function () { scramble(hash) }, t + 200);
}
function scramble(hash) {
var spans = [codes[0].children, codes[1].children], locked = [0, -14], tick = setInterval(function () {
for (var k = 0; k < 2; k++) {
locked[k] += 2;
for (var i = 0; i < 64; i++) {
var s = spans[k][i];
if (i < locked[k]) { if (!s.classList.contains('lk')) { s.textContent = hash[i]; s.classList.add('lk') } }
else s.textContent = HEX[Math.floor(Math.random() * 16)];
}
}
if (locked[1] >= 64) {
clearInterval(tick);
lab.classList.add('zci-match');
later(function () { stamp.classList.add('on') }, 250);
later(function () { busy = false; run() }, 5200);
}
}, 45);
timers.push({ clear: function () { clearInterval(tick) } });
}
new IntersectionObserver(function (es) {
visible = es[0].isIntersecting;
if (visible) run();
}, { threshold: 0.35 }).observe(lab);
})();
/* ── Мини-игра «Спасите пакеты» ──────────────────────────────────────── */
(function () {
var box = document.getElementById('zg-packets');
var cv = box && box.querySelector('canvas');
if (!cv || !cv.getContext || !window.IntersectionObserver) return;
var g = cv.getContext('2d'), W = 0, H = 0, dpr = 1, wireY = 0, visible = false, raf = 0, last = 0;
var pk = [], claws = [], fx = [], nextPk = 0, nextClaw = 1.5, clock = 0;
var score = { saved: 0, lost: 0, best: 0 }, toast = box.querySelector('.zg-toast'), shown = {};
try { score.best = +localStorage.getItem('zg-best') || 0 } catch (e) {}
function ui() { for (var k in score) box.querySelector('[data-g="' + k + '"]').textContent = score[k] }
ui();
function size() {
var r = cv.getBoundingClientRect(); dpr = Math.min(2, devicePixelRatio || 1);
W = r.width; H = r.height; cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr); wireY = H * 0.68;
}
function spawn() { pk.push({ x: -20, v: 70 + Math.random() * 60 + Math.min(60, score.saved * 1.5), s: 0, out: 0, grab: null }) }
function attack() {
var cand = pk.filter(function (p) { return !p.s && !p.grab && p.x > W * 0.25 && p.x < W * 0.8 });
if (!cand.length) return;
var t = cand[Math.floor(Math.random() * cand.length)];
claws.push({ target: t, x: t.x + t.v * 0.9, st: 'warn', t: 0 });
}
function burst(x, y, col, n) { for (var i = 0; i < n; i++) { var a = Math.random() * 6.28, v = 60 + Math.random() * 140; fx.push({ x: x, y: y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, life: 0.6, col: col }) } }
function milestone() {
var m = [10, 25, 50, 100].filter(function (n) { return score.saved >= n && !shown[n] })[0];
if (!m) return;
shown[m] = 1;
toast.querySelector('b').textContent = m + ' ' + plural(m, 'пакет спасён!', 'пакета спасены!', 'пакетов спасено!');
toast.hidden = false; clearTimeout(toast._t); toast._t = setTimeout(function () { toast.hidden = true }, 7000);
}
function drawClaw(x, y, a) {
g.save(); g.translate(x, y); g.globalAlpha = a;
g.fillStyle = 'rgba(255,43,79,.18)'; g.beginPath(); g.ellipse(0, -30, 44, 50, 0, 0, 6.28); g.fill(); // ореол без shadowBlur
g.fillStyle = '#e9b8a4'; g.beginPath(); g.ellipse(0, -46, 26, 30, 0, 0, 6.28); g.fill();
[-18, -6, 6, 18].forEach(function (dx, i) {
g.fillStyle = '#e9b8a4'; g.beginPath(); g.ellipse(dx, -18, 6, 16, dx / 60, 0, 6.28); g.fill();
g.fillStyle = '#1a0a18'; g.beginPath(); g.moveTo(dx - 5, -6); g.quadraticCurveTo(dx + dx * 0.15, 14, dx + dx * 0.25, 20); g.quadraticCurveTo(dx + 6, 6, dx + 5, -6); g.fill();
});
g.restore();
}
function frame(now) {
raf = 0; if (!visible || document.hidden) { last = 0; return }
if (root.classList.contains('zpm-intro-on')) { last = 0; setTimeout(function () { if (!raf) raf = requestAnimationFrame(frame) }, 500); return }
var dt = last ? Math.min(0.05, (now - last) / 1000) : 0; last = now; clock += dt;
if (clock > nextPk) { spawn(); nextPk = clock + 0.55 + Math.random() * 0.6 }
if (clock > nextClaw) { attack(); nextClaw = clock + Math.max(0.9, 2.6 - score.saved * 0.03) + Math.random() * 1.2 }
g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, W, H);
// провод
var grd = g.createLinearGradient(0, 0, W, 0); grd.addColorStop(0, 'rgba(79,209,255,0)'); grd.addColorStop(0.1, 'rgba(79,209,255,.7)'); grd.addColorStop(0.9, 'rgba(155,123,255,.7)'); grd.addColorStop(1, 'rgba(155,123,255,0)');
g.strokeStyle = grd; g.lineWidth = 3; g.beginPath(); g.moveTo(0, wireY); g.lineTo(W, wireY); g.stroke();
// когти
claws.forEach(function (c) {
c.t += dt;
if (c.st === 'warn') {
g.strokeStyle = 'rgba(255,59,92,' + (0.4 + 0.4 * Math.sin(c.t * 30)) + ')'; g.setLineDash([6, 6]); g.lineWidth = 2;
g.beginPath(); g.moveTo(c.x, 0); g.lineTo(c.x, wireY); g.stroke(); g.setLineDash([]);
if (c.t > 0.9) { c.st = 'down'; c.t = 0 }
} else if (c.st === 'down') {
var y = -40 + (wireY + 40) * Math.min(1, c.t / 0.22);
drawClaw(c.x, y, 1);
if (c.t >= 0.22) {
c.st = 'up'; c.t = 0;
pk.forEach(function (p) { if (!p.grab && !p.s && Math.abs(p.x - c.x) < 34) { p.grab = c; c.got = p; score.lost++; ui(); burst(p.x, wireY, '#ff3b5c', 14) } });
pk.forEach(function (p) { if (p.s && Math.abs(p.x - c.x) < 34) { burst(p.x, wireY, '#5b7fff', 10); c.blocked = 1 } });
}
} else {
var y2 = wireY - (wireY + 60) * Math.min(1, c.t / 0.5);
drawClaw(c.x, y2, 1);
if (c.got) c.got.y = y2 + 16;
if (c.t > 0.5) c.dead = 1;
}
});
claws = claws.filter(function (c) { if (c.dead && c.got) c.got.gone = 1; return !c.dead });
// пакеты
pk.forEach(function (p) {
if (!p.grab) p.x += p.v * dt * (p.s ? 1.6 : 1);
var y = p.grab ? p.y : wireY;
if (p.s) {
g.fillStyle = 'rgba(79,209,255,.14)'; g.beginPath(); g.arc(p.x, y, 22, 0, 6.28); g.fill();
g.strokeStyle = 'rgba(79,209,255,.9)'; g.lineWidth = 2;
g.beginPath(); g.arc(p.x, y, 17, 0, 6.28); g.stroke();
}
g.fillStyle = p.grab ? 'rgba(255,59,92,.22)' : 'rgba(79,209,255,.2)'; g.fillRect(p.x - 14, y - 11, 28, 22);
g.fillStyle = p.grab ? '#ff5a74' : (p.s ? '#f2eee6' : '#5b7fff');
g.beginPath(); g.roundRect ? g.roundRect(p.x - 9, y - 7, 18, 14, 4) : g.rect(p.x - 9, y - 7, 18, 14); g.fill();
if (p.x > W + 20) { p.gone = 1; if (p.s) { score.saved++; if (score.saved > score.best) { score.best = score.saved; try { localStorage.setItem('zg-best', score.best) } catch (e) {} } ui(); milestone() } }
});
pk = pk.filter(function (p) { return !p.gone });
// искры
fx.forEach(function (f) { f.life -= dt; f.x += f.vx * dt; f.y += f.vy * dt; f.vy += 300 * dt; g.globalAlpha = Math.max(0, f.life / 0.6); g.fillStyle = f.col; g.fillRect(f.x - 2, f.y - 2, 4, 4) });
g.globalAlpha = 1; fx = fx.filter(function (f) { return f.life > 0 });
raf = requestAnimationFrame(frame);
}
cv.addEventListener('pointerdown', function (e) {
var r = cv.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top, best = null, d = 40;
pk.forEach(function (p) { var dd = Math.hypot(p.x - x, (p.grab ? p.y : wireY) - y); if (!p.s && !p.grab && dd < d) { d = dd; best = p } });
if (best) { best.s = 1; burst(best.x, wireY, '#5b7fff', 12) }
claws.forEach(function (c) { if (c.st !== 'warn' && Math.abs(c.x - x) < 40 && y < wireY + 10) { if (c.got) { c.got.grab = null; c.got.s = 1; c.got = null } c.st = 'up'; c.t = 0.3; burst(c.x, y, '#ffcf5a', 16) } });
});
new IntersectionObserver(function (es) { visible = es[0].isIntersecting; if (visible) { size(); if (!raf) raf = requestAnimationFrame(frame) } }).observe(box);
addEventListener('resize', size);
document.addEventListener('visibilitychange', function () { if (visible && !raf) raf = requestAnimationFrame(frame) });
})();
/* ── Мини-игра «Сломайте замок РКН» ─────────────────────────────────── */
(function () {
var lock = document.getElementById('zlock');
if (!lock) return;
var btn = lock.querySelector('.zlock-hit'), fx = lock.querySelector('.zlock-fx'), bar = lock.querySelector('.zlock-hp i');
var hpEl = lock.querySelector('[data-l="hp"]'), name = lock.querySelector('.zlock-name'), label = lock.querySelector('.zl-label');
var win = lock.querySelector('.zlock-win'), cracks = lock.querySelectorAll('.zl-crack');
var LEVELS = [['Сломайте замок РКН', 'РКН', 15], ['Теперь замок ТСПУ', 'ТСПУ', 25], ['Финальный босс: реестр', 'РЕЕСТР', 40]];
var lvl = 0, hp = 0, max = 0;
function set(l) {
lvl = l; max = hp = LEVELS[Math.min(l, 2)][2] + Math.max(0, l - 2) * 15;
name.textContent = LEVELS[Math.min(l, 2)][0] + (l > 2 ? ' ×' + (l - 1) : '');
label.textContent = LEVELS[Math.min(l, 2)][1];
lock.classList.remove('broken'); win.hidden = true; upd();
}
function upd() {
hpEl.textContent = hp; bar.style.width = (hp / max * 100) + '%';
var k = hp / max; cracks.forEach(function (c) { c.classList.toggle('on', k <= +c.getAttribute('data-at')) });
}
function add(el, ms) { fx.appendChild(el); setTimeout(function () { el.remove() }, ms) }
function hit() {
if (hp <= 0) return;
var crit = Math.random() < 0.15, dmg = crit ? 3 : 1;
hp = Math.max(0, hp - dmg); upd();
lock.classList.remove('hit'); void lock.offsetWidth; lock.classList.add('hit');
var n = document.createElement('b'); n.textContent = crit ? 'КРИТ −3!' : '−1'; if (crit) n.className = 'crit';
n.style.setProperty('--dx', (Math.random() * 60 - 30) + 'px'); add(n, 800);
for (var i = 0; i < (crit ? 12 : 6); i++) {
var s = document.createElement('i'), a = Math.random() * 6.28, v = 40 + Math.random() * 70;
s.style.setProperty('--dx', Math.cos(a) * v + 'px'); s.style.setProperty('--dy', Math.sin(a) * v + 'px'); add(s, 600);
}
if (navigator.vibrate) try { navigator.vibrate(crit ? 30 : 10) } catch (e) {}
if (hp === 0) boom();
}
function boom() {
lock.classList.add('broken');
var cols = ['#5b7fff', '#9b7bff', '#37e39a', '#ffcf5a', '#ff6bb3'];
for (var i = 0; i < 60; i++) {
var c = document.createElement('i'), a = -Math.PI / 2 + (Math.random() - 0.5) * 2.6, v = 90 + Math.random() * 220;
c.className = 'cf'; c.style.setProperty('--c', cols[i % 5]);
c.style.setProperty('--dx', Math.cos(a) * v + 'px'); c.style.setProperty('--dy', (Math.sin(a) * v + 160) + 'px'); c.style.setProperty('--r', (Math.random() * 720 - 360) + 'deg');
add(c, 1600);
}
setTimeout(function () { win.hidden = false }, 500);
try { localStorage.setItem('zlock-lvl', lvl + 1) } catch (e) {}
}
btn.addEventListener('click', hit);
lock.querySelector('.zlock-again').addEventListener('click', function () { set(lvl + 1) });
var saved = 0; try { saved = +localStorage.getItem('zlock-lvl') || 0 } catch (e) {}
set(Math.min(saved, 2));
})();
/* ── Своё контекстное меню ──────────────────────────────────────────── */
(function () {
if (window.matchMedia && matchMedia('(pointer: coarse)').matches) return; // на телефонах — системное
var sub = (pulse && pulse.getAttribute('data-heatmap') || '/api/').split('/api/')[0];
var I = {
back: '<svg viewBox="0 0 24 24"><path d="M15 5l-7 7 7 7"/></svg>',
fwd: '<svg viewBox="0 0 24 24"><path d="M9 5l7 7-7 7"/></svg>',
reload: '<svg viewBox="0 0 24 24"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/></svg>',
top: '<svg viewBox="0 0 24 24"><path d="M12 19V5m-6 6 6-6 6 6"/></svg>',
open: '<svg viewBox="0 0 24 24"><path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/></svg>',
copy: '<svg viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/></svg>',
link: '<svg viewBox="0 0 24 24"><path d="M10 14a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1M14 10a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>',
search: '<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/></svg>',
img: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M21 16l-5-5-9 9"/></svg>',
play: '<svg viewBox="0 0 24 24"><path d="M7 4v16l13-8z"/></svg>',
heart: '<svg viewBox="0 0 24 24"><path d="M12 20s-8-5-8-11a4.5 4.5 0 0 1 8-3 4.5 4.5 0 0 1 8 3c0 6-8 11-8 11z"/></svg>',
repo: '<svg viewBox="0 0 24 24"><path d="M5 4h11l3 3v13H5z"/><path d="M9 9h6M9 13h6M9 17h4"/></svg>',
status: '<svg viewBox="0 0 24 24"><path d="M12 3l7 3v6c0 4.5-3 7.8-7 9-4-1.2-7-4.5-7-9V6z"/><path d="M8 12h2l1.5-3 2 6 1.5-3h1"/></svg>',
lock: '<svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0"/></svg>'
};
var menu = document.createElement('div'); menu.className = 'zcm'; menu.hidden = true; menu.setAttribute('role', 'menu');
document.body.appendChild(menu);
var items = [], cur = -1;
function toast(msg) { var t = document.createElement('div'); t.className = 'zcm-toast'; t.textContent = msg; document.body.appendChild(t); setTimeout(function () { t.remove() }, 1900) }
function copy(s, msg) {
(navigator.clipboard ? navigator.clipboard.writeText(s) : Promise.reject()).then(function () { toast(msg || 'Скопировано') }, function () {
var ta = document.createElement('textarea'); ta.value = s; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); toast(msg || 'Скопировано') } catch (e) {} ta.remove();
});
}
function close() { menu.hidden = true; cur = -1 }
function build(e) {
var a = e.target.closest('a[href]'), im = e.target.closest('img'), sel = String(getSelection() || '').trim();
var h = '<div class="zcm-head"><img src="' + sub + '/zapretdiscordyoutube.png" alt=""><div><b>Zapret.Moe</b><small>git-сервер сообщества</small></div></div>';
h += '<div class="zcm-row"><button data-a="back" title="Назад"' + (history.length < 2 ? ' disabled' : '') + '>' + I.back + '</button><button data-a="fwd" title="Вперёд">' + I.fwd + '</button><button data-a="reload" title="Обновить">' + I.reload + '</button><button data-a="top" title="Наверх">' + I.top + '</button></div>';
var body = [];
if (sel) body.push('<div class="zcm-lbl">«' + sel.slice(0, 40).replace(/</g, '&lt;') + (sel.length > 40 ? '…' : '') + '»</div>', it('copysel', I.copy, 'Копировать', 'Ctrl+C'), it('search', I.search, 'Найти в интернете'));
else if (a) body.push('<div class="zcm-lbl">' + a.href.replace(/^https?:\/\//, '').slice(0, 44) + '</div>', it('go', I.open, 'Открыть'), it('tab', I.open, 'Открыть в новой вкладке'), it('copylink', I.link, 'Копировать адрес ссылки'));
else if (im && im.currentSrc) body.push(it('imgopen', I.img, 'Открыть картинку'), it('imgcopy', I.link, 'Копировать адрес картинки'));
if (body.length) h += body.join('') + '<div class="zcm-sep"></div>';
h += it('intro', I.play, 'Посмотреть заставку') + it('lock', I.lock, 'Сломать замок РКН') + it('repos', I.repo, 'Все репозитории') + it('status', I.status, 'Статус сервисов');
h += '<div class="zcm-sep"></div>' + it('donate', I.heart, 'Поддержать проект', '', 'hot');
h += '<div class="zcm-foot">Shift + ПКМ — меню браузера</div>';
menu.innerHTML = h;
menu._ctx = { a: a, im: im, sel: sel };
items = Array.prototype.slice.call(menu.querySelectorAll('.zcm-it'));
}
function it(act, ico, label, kbd, cls) { return '<button class="zcm-it' + (cls ? ' ' + cls : '') + '" role="menuitem" data-a="' + act + '">' + ico + '<span>' + label + '</span>' + (kbd ? '<kbd>' + kbd + '</kbd>' : '') + '</button>' }
function act(name) {
var c = menu._ctx || {}; close();
var go = function (u, blank) { if (blank) open(u, '_blank', 'noopener'); else location.href = u };
switch (name) {
case 'back': history.back(); break;
case 'fwd': history.forward(); break;
case 'reload': location.reload(); break;
case 'top': scrollTo({ top: 0, behavior: 'smooth' }); break;
case 'copysel': copy(c.sel); break;
case 'search': go('https://duckduckgo.com/?q=' + encodeURIComponent(c.sel), 1); break;
case 'go': c.a.click(); break;
case 'tab': go(c.a.href, 1); break;
case 'copylink': copy(c.a.href, 'Ссылка скопирована'); break;
case 'imgopen': go(c.im.currentSrc, 1); break;
case 'imgcopy': copy(c.im.currentSrc, 'Адрес картинки скопирован'); break;
case 'intro': if (window.zpmIntro) { scrollTo(0, 0); window.zpmIntro.start('full') } break;
case 'lock': var l = document.getElementById('zlock'); if (l) l.scrollIntoView({ behavior: 'smooth', block: 'center' }); break;
case 'repos': go(sub + '/explore/repos'); break;
case 'status': go('https://status.zapret.moe/', 1); break;
case 'donate': go('https://t.me/zapretvpns_bot?start=site_ctxmenu', 1); break;
}
}
function focusIt(i) { items.forEach(function (x, k) { x.classList.toggle('on', k === i) }); cur = i; if (items[i]) items[i].focus({ preventScroll: true }) }
document.addEventListener('contextmenu', function (e) {
if (e.shiftKey || e.target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"], .zcm')) return;
if (document.documentElement.classList.contains('zpm-intro-on')) return;
e.preventDefault();
build(e);
menu.hidden = false;
var w = menu.offsetWidth, hh = menu.offsetHeight;
var x = Math.min(e.clientX, innerWidth - w - 8), y = Math.min(e.clientY, innerHeight - hh - 8);
menu.style.left = Math.max(8, x) + 'px'; menu.style.top = Math.max(8, y) + 'px';
menu.style.setProperty('--ox', (e.clientX - x) + 'px'); menu.style.setProperty('--oy', (e.clientY - y) + 'px');
menu.style.animation = 'none'; void menu.offsetWidth; menu.style.animation = '';
});
menu.addEventListener('click', function (e) { var b = e.target.closest('[data-a]'); if (b) act(b.getAttribute('data-a')) });
menu.addEventListener('mousemove', function (e) { var b = e.target.closest('.zcm-it'); if (b) focusIt(items.indexOf(b)) });
document.addEventListener('mousedown', function (e) { if (!menu.hidden && !menu.contains(e.target)) close() }, true);
addEventListener('scroll', function () { if (!menu.hidden) close() }, { passive: true });
addEventListener('blur', close); addEventListener('resize', close);
document.addEventListener('keydown', function (e) {
if (menu.hidden) return;
if (e.key === 'Escape') { e.preventDefault(); close() }
else if (e.key === 'ArrowDown') { e.preventDefault(); focusIt((cur + 1) % items.length) }
else if (e.key === 'ArrowUp') { e.preventDefault(); focusIt((cur - 1 + items.length) % items.length) }
else if (e.key === 'Enter' && items[cur]) { e.preventDefault(); act(items[cur].getAttribute('data-a')) }
});
})();
/* ── Курсор-пакет и анимация «блокировка → обход» по клику ─────────── */
(function () {
if (!window.matchMedia || !matchMedia('(pointer: fine)').matches || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var root = document.documentElement, body = document.body;
var dot = document.createElement('div'), ring = document.createElement('div'), cv = document.createElement('canvas');
dot.className = 'zcur-dot'; ring.className = 'zcur-ring'; cv.className = 'zcur-fx';
body.appendChild(cv); body.appendChild(ring); body.appendChild(dot);
root.classList.add('zcur-on', 'zcur-hide');
var g = cv.getContext('2d'), dpr = 1, W = 0, H = 0;
function size() { dpr = Math.min(2, devicePixelRatio || 1); W = innerWidth; H = innerHeight; cv.width = W * dpr; cv.height = H * dpr }
size(); addEventListener('resize', size);
var mx = -100, my = -100, rx = -100, ry = -100, trail = [], fx = [], raf = 0, idle = 0;
function state(t) {
var c = root.classList, el = t && t.closest ? t : null;
c.toggle('zcur-link', !!(el && el.closest('a, button, [role="button"], .zrn, .zc-dl, .zp-lg, .zlock-hit, .zg-canvas, summary, label')));
c.toggle('zcur-rkn', !!(el && el.closest('.zr-rkn, .zf-rkn, .zx-rig-rkn, .zx-rig-stamp, .zx-rig-defeat')));
c.toggle('zcur-love', !!(el && el.closest('.zj-help, .zlock, a[href*="zapretvpns_bot"]')));
c.toggle('zcur-text', !!(el && !el.closest('a, button') && el.closest('p, h1, h2, h3, li span, dd, code, .zt-t') && !el.closest('.zr-rkn')));
}
addEventListener('pointermove', function (e) {
if (e.pointerType !== 'mouse') return;
mx = e.clientX; my = e.clientY; root.classList.remove('zcur-hide');
trail.push({ x: mx, y: my, t: performance.now() });
state(e.target); kick();
}, { passive: true });
document.addEventListener('mouseleave', function () { root.classList.add('zcur-hide') });
addEventListener('pointerdown', function (e) {
if (e.pointerType !== 'mouse' || e.button !== 0) return;
root.classList.add('zcur-down');
var hot = e.target.closest && e.target.closest('a, button, [role="button"], .zrn, .zlock-hit, .zg-canvas, input, textarea, select');
if (root.classList.contains('zcur-love')) hearts(e.clientX, e.clientY);
else if (hot) ripple(e.clientX, e.clientY); else block(e.clientX, e.clientY);
kick();
});
addEventListener('pointerup', function () { root.classList.remove('zcur-down') });
function ripple(x, y) {
fx.push({ k: 'ring', x: x, y: y, t: 0, dur: 0.5, col: '79,209,255' });
for (var i = 0; i < 8; i++) { var a = i / 8 * 6.28; fx.push({ k: 'spark', x: x, y: y, vx: Math.cos(a) * 160, vy: Math.sin(a) * 160, t: 0, dur: 0.4, col: '191,240,255' }) }
}
function hearts(x, y) {
for (var i = 0; i < 10; i++) { var a = -Math.PI / 2 + (Math.random() - 0.5) * 2.2, v = 90 + Math.random() * 150; fx.push({ k: 'heart', x: x, y: y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, t: 0, dur: 0.9 + Math.random() * 0.4, s: 6 + Math.random() * 7, h: [350, 335, 320][i % 3] }) }
}
// «блокировка»: красная рамка ТСПУ схлопывается — пакет раскалывается и уходит в обход
function block(x, y) { fx.push({ k: 'block', x: x, y: y, t: 0, dur: 0.75, ang: Math.random() * 1.2 - 0.6 }) }
function kick() { if (!raf) raf = requestAnimationFrame(frame) }
var last = 0;
function frame(now) {
raf = 0;
var dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016; last = now;
rx += (mx - rx) * Math.min(1, dt * 18); ry += (my - ry) * Math.min(1, dt * 18);
dot.style.transform = 'translate(' + mx + 'px,' + my + 'px)';
ring.style.transform = 'translate(' + rx + 'px,' + ry + 'px)';
g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, W, H);
// шлейф-провод
while (trail.length && now - trail[0].t > 260) trail.shift();
var love = root.classList.contains('zcur-love');
if (trail.length > 1) {
g.lineCap = 'round'; g.lineJoin = 'round';
for (var i = 1; i < trail.length; i++) {
var p = trail[i - 1], q = trail[i], a = 1 - (now - q.t) / 260;
g.strokeStyle = love ? 'rgba(255,' + (107 + (1 - a) * 60 | 0) + ',131,' + (a * 0.6) + ')' : 'rgba(' + (79 + (1 - a) * 76 | 0) + ',' + (209 - (1 - a) * 86 | 0) + ',255,' + (a * 0.55) + ')';
g.lineWidth = 1 + a * 2.5; g.beginPath(); g.moveTo(p.x, p.y); g.lineTo(q.x, q.y); g.stroke();
}
}
fx.forEach(function (f) {
f.t += dt; var k = f.t / f.dur;
if (f.k === 'ring') {
g.strokeStyle = 'rgba(' + f.col + ',' + (1 - k) + ')'; g.lineWidth = 2;
g.beginPath(); g.arc(f.x, f.y, 8 + k * 40, 0, 6.28); g.stroke();
} else if (f.k === 'spark') {
f.x += f.vx * dt; f.y += f.vy * dt; f.vx *= 0.9; f.vy *= 0.9;
g.fillStyle = 'rgba(' + f.col + ',' + (1 - k) + ')'; g.fillRect(f.x - 1.5, f.y - 1.5, 3, 3);
} else if (f.k === 'heart') {
f.x += f.vx * dt; f.y += f.vy * dt; f.vy += 160 * dt; f.vx *= 0.97;
var s = f.s * (k < 0.15 ? k / 0.15 : 1);
g.save(); g.translate(f.x, f.y); g.rotate(f.vx * 0.002); g.globalAlpha = 1 - k * k;
g.fillStyle = 'hsl(' + f.h + ',90%,66%)';
g.beginPath(); g.moveTo(0, s * 0.9);
g.bezierCurveTo(-s * 1.4, 0, -s * 0.9, -s, 0, -s * 0.35);
g.bezierCurveTo(s * 0.9, -s, s * 1.4, 0, 0, s * 0.9); g.fill(); g.restore();
} else if (f.k === 'block') {
g.save(); g.translate(f.x, f.y);
var s, close = Math.min(1, k / 0.3);
if (k < 0.42) {
// рамка схлопывается вокруг пакета
s = 44 - close * 26;
g.rotate(f.ang * (1 - close));
g.strokeStyle = 'rgba(255,59,92,' + (0.4 + close * 0.6) + ')'; g.lineWidth = 2.5;
g.strokeRect(-s / 2, -s / 2, s, s);
g.fillStyle = 'rgba(255,107,131,' + close + ')'; g.font = '700 9px ui-monospace, monospace'; g.textAlign = 'center';
g.fillText('ТСПУ', 0, -s / 2 - 5);
g.fillStyle = '#5b7fff'; g.fillRect(-6, -4.5, 12, 9);
if (k > 0.3) { g.fillStyle = 'rgba(255,255,255,' + (1 - (k - 0.3) / 0.12) + ')'; g.beginPath(); g.arc(0, 0, 16, 0, 6.28); g.fill() }
} else {
// пакет раскалывается и уходит в обход, рамка ломается
var e = (k - 0.42) / 0.58, ease = 1 - Math.pow(1 - e, 3), d = ease * 46;
g.rotate(f.ang);
g.globalAlpha = 1 - e;
g.strokeStyle = '#ff3b5c'; g.lineWidth = 2.5;
[[-1, -1, 1, 0], [1, -1, 0, 1], [1, 1, -1, 0], [-1, 1, 0, -1]].forEach(function (c, j) {
g.save(); g.translate(c[0] * (9 + d * 0.8), c[1] * (9 + d * 0.8)); g.rotate((j % 2 ? 1 : -1) * ease * 1.2);
g.beginPath(); g.moveTo(-9, 0); g.lineTo(9, 0); g.stroke(); g.restore();
});
g.globalAlpha = 1 - e * 0.8;
g.fillStyle = '#5b7fff';
g.fillRect(-6 - d * 1.2, -4.5 - d * 0.5, 5.5, 9);
g.fillRect(0.5 + d * 1.2, -4.5 + d * 0.5, 5.5, 9);
g.fillStyle = 'rgba(79,209,255,' + (1 - e) + ')'; g.font = '700 10px ui-monospace, monospace'; g.textAlign = 'center';
g.fillText('обход ✓', 0, -20 - d * 0.4);
}
g.restore();
}
});
fx = fx.filter(function (f) { return f.t < f.dur });
if (Math.abs(mx - rx) + Math.abs(my - ry) > 0.3 || trail.length || fx.length) raf = requestAnimationFrame(frame);
else last = 0;
}
})();
/* ── На связи: программы, которые ждут сигнал о новой версии ─────────
Числа раз в минуту считает сервер (служба очереди обновлений) и кладёт
в stats.json. Нет файла или в нём пусто — раздела на странице нет.
Ряд приходит строками [время, dev, stable, на последней версии, выдано
разрешений, обновилось, не вышло]; кривая выпуска — парами [секунд от
выхода, доля в ‰]. Графики появляются плавно, когда раздел впервые попал
на экран и когда человек сам что-то переключил; при ежеминутном
обновлении чисел они не дёргаются. */
(function () {
var live = document.getElementById('zpm-live');
if (!live || !window.fetch) return;
var SVG = 'http://www.w3.org/2000/svg';
var CH = [['stable', 'Stable', 2], ['dev', 'Dev', 1]]; // ключ, подпись, столбец ряда
var S = null, first = true, range = '24h', on = { stable: true, dev: true }, picked = '';
var zoom = -1; // отрезок кривой выпуска, секунд; 0 — всё время, -1 — выбрать самому
var sorts = { days: ['d', -1], rolls: ['released_at', -1] };
var still = !!(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches);
var seen = false; // раздел уже побывал на экране
var LIST = 8; // выпусков в списке слева; в таблице — все
/* две программы — два итога сервера; подписи отличаются словами про своё */
var PRODUCTS = {
gui: { url: live.getAttribute('data-stats'), scope: 'Пока в счёте только Zapret 2 GUI начиная с версии 21.1.7.121 — число растёт по мере обновления.', run: 'обход включён' },
kvn: { url: live.getAttribute('data-stats-kvn'), scope: 'В счёте Zapret KVN для Windows начиная с выпуска, в котором появился сигнал сервера, — число растёт по мере обновления.', run: 'подключение включено' }
};
var product = 'gui', chosen = false; // chosen — человек сам переключил программу
function q(sel) { return live.querySelector(sel) }
function cell(key) { return q('[data-k="' + key + '"]') }
function show(key, visible) {
var box = cell(key);
while (box && !box.classList.contains('zp-kpi')) box = box.parentNode;
if (box) box.hidden = !visible;
}
function put(key, text) { var el = cell(key); if (el) el.textContent = text }
function two(n) { return ('0' + n).slice(-2) }
function clock(ts) { var d = new Date(ts * 1000); return two(d.getHours()) + ':' + two(d.getMinutes()) }
function day(ts) { var d = new Date(ts * 1000); return d.getDate() + ' ' + MONTHS[d.getMonth()] }
function span(s) {
if (s < 1) return 'сразу';
if (s < 90) return Math.round(s) + ' с';
if (s < 5400) return Math.round(s / 60) + ' мин';
if (s < 48 * 3600) return (s / 3600).toLocaleString('ru-RU', { maximumFractionDigits: 1 }) + ' ч';
return Math.round(s / 86400) + ' дн.';
}
/* «15–30 с», «2–5 мин»: единица пишется один раз, если она у границ общая */
function between(a, b) {
var u = b <= 60 ? [1, ' с'] : b <= 3600 && a >= 60 ? [60, ' мин'] : null;
return u ? a / u[0] + '–' + b / u[0] + u[1] : span(a) + ' – ' + span(b);
}
/* за сколько версия дошла до доли программ. У прежних выпусков время
мерилось минутами: «дошла за 0 минут» честно зовётся «меньше минуты» */
function reach(r, key) {
var s = r[key];
return s != null && s < 60 && !r.exact ? 'меньше минуты' : within(s);
}
function within(s) { return s == null ? 'ещё нет' : (s < 1 ? '' : 'за ') + span(s) }
function pct(share) { return (share * 100).toLocaleString('ru-RU', { maximumFractionDigits: share < 0.1 ? 1 : 0 }) + '%' }
function niceTop(v) {
if (v <= 10) return Math.max(2, Math.ceil(v / 2) * 2);
var mag = Math.pow(10, Math.floor(Math.log(v) / Math.LN10)) / 2;
return Math.ceil(v / mag) * mag;
}
/* Холст графика: размеры по месту, узлы SVG и подсказка у курсора. */
function stage(box, padL, animate) {
var svg = box.querySelector('svg'), tip = box.querySelector('.zp-tip');
/* класс снимается и ставится заново: так появление проигрывается ещё раз */
svg.classList.remove('zw-anim');
if (animate && !still) { void svg.getBoundingClientRect(); svg.classList.add('zw-anim') }
var w = Math.max(300, box.clientWidth || 900), h = Math.max(120, box.clientHeight || 180);
svg.textContent = '';
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
tip.hidden = true;
var g = { svg: svg, tip: tip, w: w, h: h, l: padL, r: w - 6, t: 10, b: h - 24 };
/* подписи времени под графиком: ticks+1 штука от начала до конца */
g.times = function (t0, t1, x, label) {
var ticks = w < 560 ? 3 : 6;
for (var i = 0; i <= ticks; i++) {
var t = t0 + (t1 - t0) * i / ticks;
g.node('text', { 'class': 'zp-xl', x: Math.min(g.r - 20, Math.max(g.l + 18, x(t))), y: h - 4 }, label(t, i));
}
};
g.node = function (name, attrs, text, parent) {
var el = document.createElementNS(SVG, name);
for (var k in attrs) el.setAttribute(k, attrs[k]);
if (text != null) el.textContent = text;
(parent || svg).appendChild(el);
return el;
};
g.grid = function (top, label) {
[0, 0.5, 1].forEach(function (k) {
var y = g.b - (g.b - g.t) * k;
g.node('line', { 'class': 'zp-gl', x1: g.l, x2: g.r, y1: y, y2: y });
g.node('text', { 'class': 'zp-yl', x: g.l - 8, y: y + 4 }, label(top * k));
});
};
g.say = function (x, title, rows) {
fillTip(tip, title, rows);
var half = tip.offsetWidth / 2;
tip.style.left = Math.min(w - half - 2, Math.max(half + 2, x)) + 'px';
};
return g;
}
function color(key) { return getComputedStyle(live).getPropertyValue('--zw-' + key).trim() || '#5b7fff' }
/* подсказка у курсора: первая строка — заголовок, дальше [цвет|null, подпись, значение] */
function fillTip(tip, title, rows) {
tip.textContent = '';
var head = document.createElement('p'); head.textContent = title; tip.appendChild(head);
rows.forEach(function (r) {
var p = document.createElement('p');
if (r[0]) { var key = document.createElement('i'); key.style.background = r[0]; p.appendChild(key) }
var val = document.createElement('b'); val.textContent = r[2]; p.appendChild(val);
p.appendChild(document.createTextNode(r[1]));
tip.appendChild(p);
});
tip.hidden = false;
}
/* ── Живая сценка ────────────────────────────────────────────────────
Сервер в середине, программы вокруг него. У каждой — открытое
соединение (тонкая нить). Сервер держит вопрос «есть новее?» до
четырёх с половиной минут; срок вышел — отвечает «пока нет», и
программа спрашивает снова. Этих переспросов на экране столько,
сколько сервер насчитал за последнюю минуту. Когда выходит версия,
от сервера к программам идут разрешения обновиться. */
var scene = (function () {
var box = q('.zw-scene'), cv = box.querySelector('canvas'), log = box.querySelector('.zw-log');
var ctx = cv.getContext && cv.getContext('2d');
if (!ctx) return { update: function () {} };
var MAX_DOTS = 420, MAX_RATE = 7; // точек на холсте и пакетов в секунду — больше глаз не разберёт
var W = 0, H = 0, dpr = 1, dots = [], fly = [], rings = [], raf = 0, last = 0;
var rate = 0, askDebt = 0, grantsLeft = 0, grantDebt = 0, logAt = 0, visible = false, grantedSeen = null;
var info = { latest: '', channel: 'dev', old: '' };
var paint = {};
function tone(name, fallback) { return getComputedStyle(live).getPropertyValue(name).trim() || fallback }
function size() {
dpr = Math.min(2, window.devicePixelRatio || 1);
W = cv.clientWidth; H = cv.clientHeight;
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
paint = { acc: tone('--acc', '#5b7fff'), old: tone('--zw-dev', '#c2803a'), gold: tone('--gold', '#e9bd55'), ink: tone('--ink', '#f2eee6'), mut: tone('--mut', '#a9a69f'), dim: tone('--dim', '#6f6d69'), mono: tone('--mono', 'monospace') };
place();
}
/* точки лежат «подсолнухом»: ровно заполняют овал и не слипаются */
function place() {
var n = dots.length, rx = W / 2 - 10, ry = H / 2 - 10, hole = Math.min(0.3, 54 / Math.max(1, Math.min(rx, ry)));
dots.forEach(function (d, i) {
var k = hole + (1 - hole) * Math.sqrt((i + 0.5) / n), a = i * 2.399963;
d.x = W / 2 + Math.cos(a) * rx * k; d.y = H / 2 + Math.sin(a) * ry * k;
});
}
function say(code, gloss, grant) {
var li = document.createElement('li');
if (grant) li.className = 'zw-log-grant';
var c = document.createElement('code'); c.textContent = code; li.appendChild(c);
li.appendChild(document.createTextNode(gloss));
log.insertBefore(li, log.firstChild);
while (log.children.length > 4) log.removeChild(log.lastChild);
}
function sayAsk(d, now) {
if (now - logAt < 1500) return;
logAt = now;
var known = d.old && info.old ? info.old : info.latest;
say('GET /api/wait?channel=' + info.channel + '&known=' + known + (d.busy ? '&busy=1' : ''),
d.busy ? 'программа: «я на ' + known + ', но сейчас занята — не обновляйте»' : 'программа: «я на ' + known + ', есть новее?» — сервер держит вопрос открытым');
}
function ask(now) {
var d = dots[Math.floor(Math.random() * dots.length)];
if (!d || d.wait) return;
/* срок ожидания вышел: сервер отвечает «пока нет», программа тут же спрашивает снова */
d.wait = true;
fly.push({ d: d, t: 0, dur: 0.7, back: true, kind: 'no', then: function () {
fly.push({ d: d, t: 0, dur: 0.8, back: false, kind: 'ask', then: function () { d.wait = false; rings.push({ t: 0, dur: 0.5, r: 12, gold: false }) } });
sayAsk(d, performance.now());
} });
}
function grant() {
var pool = dots.filter(function (d) { return d.old && !d.busy && !d.wait });
var d = pool[Math.floor(Math.random() * pool.length)];
if (!d) return;
d.wait = true;
rings.push({ t: 0, dur: 0.6, r: 12, gold: true });
say('{"version":"' + info.latest + '","changed":true}', 'сервер: «вышла ' + info.latest + ', можно обновляться»', true);
fly.push({ d: d, t: 0, dur: 0.9, back: true, kind: 'grant', then: function () {
d.glow = 1.6; // программа качает и ставит версию
setTimeout(function () {
d.old = false; d.wait = false;
fly.push({ d: d, t: 0, dur: 0.8, back: false, kind: 'ask', then: function () {} });
say('GET /api/wait?channel=' + info.channel + '&known=' + info.latest + '&prev=' + (info.old || '…') + '&took=24', 'программа: «обновилась, снова на связи» — по этим ответам сервер открывает версию следующим', true);
}, 1600);
} });
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
var cx = W / 2, cy = H / 2;
/* нити — открытые соединения: одна на программу */
ctx.beginPath();
dots.forEach(function (d) { ctx.moveTo(cx, cy); ctx.lineTo(d.x, d.y) });
ctx.strokeStyle = 'rgba(242,238,230,' + (dots.length > 200 ? 0.035 : 0.06) + ')'; ctx.lineWidth = 1; ctx.stroke();
dots.forEach(function (d) {
var c = d.old ? paint.old : paint.acc;
if (d.glow > 0) {
ctx.beginPath(); ctx.arc(d.x, d.y, 4 + (1.6 - d.glow) * 9, 0, 6.2832);
ctx.strokeStyle = paint.gold; ctx.globalAlpha = Math.max(0, d.glow / 1.6); ctx.lineWidth = 1.5; ctx.stroke(); ctx.globalAlpha = 1;
c = paint.gold;
}
ctx.beginPath(); ctx.arc(d.x, d.y, d.r, 0, 6.2832);
if (d.busy) { ctx.strokeStyle = paint.mut; ctx.lineWidth = 1.5; ctx.stroke() }
else { ctx.fillStyle = c; ctx.globalAlpha = d.tray && !(d.glow > 0) ? 0.4 : 1; ctx.fill(); ctx.globalAlpha = 1 }
});
/* пакеты: вопрос светлый, «пока нет» серый, разрешение золотое */
fly.forEach(function (p) {
var k = p.t / p.dur, e = k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2;
var u = p.back ? e : 1 - e;
var x = cx + (p.d.x - cx) * u, y = cy + (p.d.y - cy) * u;
ctx.save(); ctx.translate(x, y); ctx.rotate(Math.atan2(p.d.y - cy, p.d.x - cx));
ctx.fillStyle = p.kind === 'grant' ? paint.gold : p.kind === 'ask' ? '#bff0ff' : paint.dim;
var s = p.kind === 'grant' ? 1.3 : 1;
ctx.fillRect(-5 * s, -3 * s, 10 * s, 6 * s);
ctx.restore();
/* за пакетом светится его нить */
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(p.d.x, p.d.y);
ctx.strokeStyle = p.kind === 'grant' ? 'rgba(233,189,85,' + (0.5 * (1 - k)) + ')' : 'rgba(91,127,255,' + (0.35 * (1 - k)) + ')';
ctx.lineWidth = 1; ctx.stroke();
});
rings.forEach(function (r) {
var k = r.t / r.dur;
ctx.beginPath(); ctx.arc(cx, cy, r.r + k * 22, 0, 6.2832);
ctx.strokeStyle = r.gold ? paint.gold : paint.acc; ctx.globalAlpha = (1 - k) * 0.8; ctx.lineWidth = 1.5; ctx.stroke(); ctx.globalAlpha = 1;
});
/* сервер: квадрат-узел, на котором сходятся все нити */
ctx.fillStyle = tone('--bg', '#07080c'); ctx.fillRect(cx - 13, cy - 13, 26, 26);
ctx.strokeStyle = paint.ink; ctx.lineWidth = 2; ctx.strokeRect(cx - 12, cy - 12, 24, 24);
ctx.fillStyle = paint.acc; ctx.fillRect(cx - 5, cy - 5, 10, 10);
ctx.fillStyle = paint.mut; ctx.font = '11px ' + paint.mono; ctx.textAlign = 'center';
ctx.fillText('сервер', cx, cy + 30);
}
function frame(now) {
raf = 0;
var dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016; last = now;
askDebt += rate * dt;
while (askDebt >= 1) { askDebt -= 1; ask(now) }
if (grantsLeft > 0) {
grantDebt += Math.max(0.6, grantsLeft / 20) * dt;
while (grantDebt >= 1 && grantsLeft > 0) { grantDebt -= 1; grantsLeft -= 1; grant() }
}
fly = fly.filter(function (p) { p.t += dt; if (p.t >= p.dur) { p.then(); return false } return true });
rings = rings.filter(function (r) { r.t += dt; return r.t < r.dur });
dots.forEach(function (d) { if (d.glow > 0) d.glow -= dt });
draw();
go();
}
function go() { if (!raf && visible && !document.hidden && !still && dots.length) raf = requestAnimationFrame(frame) }
function update(s) {
var total = s.online.total || 0;
box.hidden = !total;
if (box.hidden) return;
var per = Math.max(1, Math.ceil(total / MAX_DOTS)), n = Math.max(1, Math.round(total / per));
var latest = s.latest || {}, onLatest = 0, old = '';
(s.online.versions || []).forEach(function (v) {
if (v.channel && latest[v.channel] === v.version) onLatest += v.count;
else if (!old && v.version !== 'other') old = v.version;
});
info.channel = latest.dev && (s.online.dev || 0) >= (s.online.stable || 0) ? 'dev' : 'stable';
info.latest = latest[info.channel] || latest.dev || latest.stable || '';
info.old = old;
var oldDots = Math.round((total - onLatest) / per), busyDots = Math.min(n, Math.round((s.online.busy || 0) / per));
var act = s.online.activity || {}, told = 0;
for (var name in act) told += act[name] || 0;
var trayDots = told ? Math.round(n * (act.tray || 0) / told) : 0;
if (dots.length !== n) {
dots = [];
for (var i = 0; i < n; i++) dots.push({ x: 0, y: 0, r: n > 260 ? 2 : n > 120 ? 2.6 : 3.2, old: false, busy: false, tray: false, wait: false, glow: 0 });
}
/* кто на прежней версии и кто занят — раскиданы по полю, а не кучкой с краю */
var now = dots.filter(function (d) { return d.old }).length;
dots.forEach(function (d, i) {
if (now < oldDots && !d.old && (i * 7919) % n < oldDots * 2) { d.old = true; now++ }
else if (now > oldDots && d.old && !d.wait) { d.old = false; now-- }
d.busy = (i * 104729) % n < busyDots;
/* доля «в трее» — среди тех, кто о себе говорит; остальные рисуются как открытые */
d.tray = (i * 15485863) % n < trayDots;
});
put('scene-scale', per > 1 ? 'точка — ' + fmt(per) + ' ' + plural(per, 'программа', 'программы', 'программ') : 'точка — одна программа');
var rpm = s.requests_min != null ? s.requests_min : Math.round(total / 4.5);
put('rpm', fmt(rpm)); put('held', fmt(total));
var busyBox = cell('busy').parentNode; busyBox.hidden = !(s.online.busy > 0); put('busy', fmt(s.online.busy || 0));
rate = Math.min(MAX_RATE, rpm / 60);
/* разрешения, выданные с прошлого обновления чисел, разыгрываются на сценке */
var granted = 0;
for (var key in (s.rollout || {})) granted += s.rollout[key].granted || 0;
if (grantedSeen != null && granted > grantedSeen) grantsLeft += Math.min(120, Math.ceil((granted - grantedSeen) / per));
grantedSeen = granted;
if (!W || cv.clientWidth !== W) size(); else place();
if (!log.children.length && info.latest) {
say('{"version":"' + info.latest + '","changed":false}', 'сервер: «новее ' + info.latest + ' пока нет — спросите снова»');
say('GET /api/wait?channel=' + info.channel + '&known=' + info.latest, 'программа: «я на ' + info.latest + ', есть новее?» — сервер держит вопрос открытым');
}
draw();
go();
}
if (window.IntersectionObserver) {
new IntersectionObserver(function (es) {
visible = es.some(function (e) { return e.isIntersecting });
last = 0; go();
}, { rootMargin: '80px 0px' }).observe(cv);
} else visible = true;
document.addEventListener('visibilitychange', function () { last = 0; go() });
window.addEventListener('resize', function () { if (dots.length) { size(); draw() } });
function reset() { dots = []; fly = []; rings = []; grantsLeft = 0; grantedSeen = null; log.textContent = '' }
return { update: update, reset: reset };
})();
/* чем заняты программы — по их собственным словам; считается общим числом */
var ACT = [
['window', 'окно открыто', 'zw-now'],
['tray', 'в трее, работают в фоне', ''],
['fullscreen', 'игра или видео на весь экран', 'zw-busy'],
['blockcheck', 'проверяют сеть', 'zw-busy'],
['strategy_scan', 'подбирают стратегию', 'zw-busy']
];
function drawActivity() {
var box = q('.zw-act'), bar = box.querySelector('.zw-bar'), list = box.querySelector('.zw-list');
var a = S.online.activity || {}, total = 0;
ACT.forEach(function (c) { total += a[c[0]] || 0 });
box.hidden = !total;
if (box.hidden) return;
bar.textContent = ''; list.textContent = '';
ACT.forEach(function (c) {
var n = a[c[0]] || 0;
if (!n) return;
var seg = document.createElement('i');
seg.style.flexGrow = String(n);
seg.className = c[2];
seg.setAttribute('role', 'img');
seg.setAttribute('aria-label', c[1] + ': ' + n + ', ' + pct(n / total));
bar.appendChild(seg);
var li = document.createElement('li');
li.className = c[2];
var name = document.createElement('b'); name.textContent = c[1]; li.appendChild(name);
li.appendChild(document.createTextNode(' ' + fmt(n) + ' · ' + pct(n / total)));
list.appendChild(li);
});
var run = S.online.running || [0, 0], notes = [];
if (run[1]) notes.push(PRODUCTS[product].run + ' у ' + fmt(run[0]) + ' из ' + fmt(run[1]) + ' (' + pct(run[0] / run[1]) + ')');
if (total < (S.online.total || 0)) notes.push('о себе говорят ' + fmt(total) + ' из ' + fmt(S.online.total) + ' — остальные на прежних версиях программы');
put('act-note', notes.join(' · '));
}
/* хвост раздачи: почему программы на связи ещё на прежней версии */
var TAIL = [
['busy', 'играют или смотрят видео — обновятся, когда человек закончит', 'zw-busy'],
['stage', 'ещё не их ступень раздачи', 'zw-now'],
['queued', 'ждут очереди на скачивание', ''],
['other', 'прямо сейчас качают и ставят или только что запустились', '']
];
function drawTail() {
var box = q('.zw-tail'), bar = box.querySelector('.zw-bar'), list = box.querySelector('.zw-list');
var parts = {}, old = 0;
Object.keys(S.tail || {}).forEach(function (ch) {
var t = S.tail[ch] || {};
old += t.old || 0;
TAIL.forEach(function (c) { parts[c[0]] = (parts[c[0]] || 0) + (t[c[0]] || 0) });
});
box.hidden = !old;
if (box.hidden) return;
bar.textContent = ''; list.textContent = '';
TAIL.forEach(function (c) {
var n = parts[c[0]] || 0;
if (!n) return;
var seg = document.createElement('i');
seg.style.flexGrow = String(n);
seg.className = c[2];
seg.setAttribute('role', 'img');
seg.setAttribute('aria-label', c[1] + ': ' + n + ', ' + pct(n / old));
bar.appendChild(seg);
var li = document.createElement('li');
li.className = c[2];
var name = document.createElement('b'); name.textContent = c[1]; li.appendChild(name);
li.appendChild(document.createTextNode(' ' + fmt(n) + ' · ' + pct(n / old)));
list.appendChild(li);
});
put('tail-note', fmt(old) + ' из ' + fmt(S.online.total || old) + ' на связи');
}
/* коды причин, которые присылают программы (why — не поставилось, miss — не скачалось) */
var WHY = {
gui: {
exit1: 'установщик не смог начать', exit2: 'установку отменили до начала', exit3: 'установщик сломался между шагами',
exit4: 'установщик сломался посреди установки', exit5: 'установщик прервал установку — обычно файл занят',
exit6: 'установщик закрыли принудительно', exit7: 'установщик отказался ставить', exit8: 'установщик просит перезапустить Windows',
same: 'установщик отчитался, а версия на диске прежняя', tamper: 'файл установщика изменили после проверки',
gone: 'файл установщика пропал — часто антивирус', start: 'установщик не запустился — антивирус или права',
cut: 'установка оборвалась — перезагрузка или выключение'
},
kvn: {
crash: 'новая версия падала при запуске — вернули прежнюю', lock: 'файл программы держит другой процесс',
swap: 'не вышло заменить файлы программы', prep: 'не вышло подготовить файлы — место или права',
close: 'программа не закрылась перед заменой'
},
all: {
net: 'сеть: файл не скачался', hash: 'файл не совпал с контрольной суммой', disk: 'нет места на диске или нельзя записать',
meta: 'не получили данные выпуска', handoff: 'не смогли передать файл установщику', badzip: 'архив повреждён',
bad: 'архив отклонён', old: 'прежние версии программы причину не называют', other: 'причина не распознана'
}
};
function whyText(code) {
var own = WHY[product] || {};
if (own[code]) return own[code];
if (WHY.all[code]) return WHY.all[code];
var m = /^exitx([0-9a-f]+)$/.exec(code);
if (m) return 'установщик аварийно завершился (0x' + m[1].toUpperCase() + ')';
m = /^exit(\d+)$/.exec(code);
if (m) return 'установщик вернул код ' + m[1];
return code;
}
function drawWhy() {
var box = q('.zw-why'), u = S.updates_24h || {}, any = false;
['why', 'miss'].forEach(function (kind) {
var col = box.querySelector('[data-w="' + kind + '"]'), list = col.querySelector('.zw-list');
var codes = u[kind] || {}, keys = Object.keys(codes).filter(function (k) { return codes[k] > 0 });
keys.sort(function (a, b) { return codes[b] - codes[a] || (a < b ? -1 : 1) });
list.textContent = '';
col.hidden = !keys.length;
if (!keys.length) return;
any = true;
keys.slice(0, 8).forEach(function (k) {
var li = document.createElement('li');
var name = document.createElement('b'); name.textContent = fmt(codes[k]); li.appendChild(name);
li.appendChild(document.createTextNode(' ' + whyText(k)));
li.title = k;
list.appendChild(li);
});
});
box.hidden = !any;
}
function drawVersions() {
var wire = q('.zw-wire'), bar = wire.querySelector('.zw-bar'), list = wire.querySelector('.zw-list'), tip = wire.querySelector('.zp-tip');
var items = (S.online.versions || []).filter(function (v) { return v.count > 0 });
var total = S.online.total || 1;
wire.hidden = !items.length;
bar.textContent = ''; list.textContent = '';
tip.hidden = true;
bar.classList.remove('zw-hov');
var pairs = [];
function tell(i) {
var v = items[i], seg = pairs[i][0];
pairs.forEach(function (p, k) { p[0].classList.toggle('zw-hot', k === i); p[1].classList.toggle('zw-hot', k === i) });
bar.classList.add('zw-hov');
var rows = [[null, ' ' + plural(v.count, 'программа', 'программы', 'программ') + ' на связи', fmt(v.count)], [null, ' от всех на связи', pct(v.count / total)]];
if (v.users) rows.push([null, ' ' + plural(v.users, 'пользователь', 'пользователя', 'пользователей') + ' за сегодня', fmt(v.users)]);
var last = !!v.channel && S.latest && S.latest[v.channel] === v.version;
fillTip(tip, (v.version === 'other' ? 'остальные версии' : v.version + (v.channel ? ' · ' + v.channel : '')) + (last ? ' · последняя' : ''), rows);
var half = tip.offsetWidth / 2, mid = seg.offsetLeft + seg.offsetWidth / 2;
tip.style.left = Math.min(wire.clientWidth - half, Math.max(half, mid)) + 'px';
/* над полосой: подсказка не закрывает ни отрезок под курсором, ни строки версий */
tip.style.top = (bar.offsetTop - tip.offsetHeight - 12) + 'px';
}
function hush() {
pairs.forEach(function (p) { p[0].classList.remove('zw-hot'); p[1].classList.remove('zw-hot') });
bar.classList.remove('zw-hov');
tip.hidden = true;
}
items.forEach(function (v, i) {
var now = !!v.channel && S.latest && S.latest[v.channel] === v.version;
var seg = document.createElement('i');
seg.style.flexGrow = String(v.count);
seg.tabIndex = 0;
seg.setAttribute('role', 'img');
seg.setAttribute('aria-label', v.version + ': ' + v.count + ', ' + pct(v.count / total));
if (now) seg.className = 'zw-now';
bar.appendChild(seg);
var li = document.createElement('li');
li.tabIndex = 0;
if (now) li.className = 'zw-now';
var name = document.createElement('b');
name.textContent = v.version === 'other' ? 'остальные' : v.version;
li.appendChild(name);
/* всё, что есть в подсказке, написано и в строке: наведение ничего не прячет */
li.appendChild(document.createTextNode(' ' + fmt(v.count) + ' · ' + pct(v.count / total) + (v.channel ? ' · ' + v.channel : '')));
list.appendChild(li);
pairs.push([seg, li]);
[seg, li].forEach(function (el) {
el.addEventListener('pointerenter', function () { tell(i) });
el.addEventListener('focus', function () { tell(i) });
el.addEventListener('pointerleave', hush);
el.addEventListener('blur', hush);
});
});
}
function drawOnline(animate) {
var box = q('.zw-day'), rows = ((S.series || {})[range]) || [];
var keys = CH.filter(function (c) { return on[c[0]] });
box.hidden = rows.length < 2;
q('.zw-ctl').hidden = ((S.series || {})['24h'] || []).length < 2;
if (box.hidden) return;
var g = stage(box.querySelector('.zw-chart'), 48, animate);
var t0 = rows[0][0], t1 = rows[rows.length - 1][0];
function sum(r) { var s = 0; keys.forEach(function (c) { s += r[c[2]] }); return s }
var top = niceTop(Math.max.apply(null, rows.map(sum)) || 1);
function x(t) { return g.l + (t - t0) / Math.max(1, t1 - t0) * (g.r - g.l) }
function y(v) { return g.b - v / top * (g.b - g.t) }
g.grid(top, short);
/* каналы лежат друг на друге: верхняя граница — все программы вместе */
var base = rows.map(function () { return 0 });
keys.forEach(function (c) {
var up = rows.map(function (r, i) { return base[i] + r[c[2]] });
var line = rows.map(function (r, i) { return x(r[0]).toFixed(1) + ',' + y(up[i]).toFixed(1) });
var back = rows.map(function (r, i) { return x(r[0]).toFixed(1) + ',' + y(base[i]).toFixed(1) }).reverse();
g.node('polygon', { 'class': 'zw-fade zw-a-' + c[0], points: line.concat(back).join(' ') });
g.node('polyline', { 'class': 'zw-draw zw-l-' + c[0], pathLength: 1, points: line.join(' ') });
base = up;
});
g.times(t0, t1, x, function (t) { return range === '24h' ? clock(t) : day(t) });
var cur = g.node('line', { 'class': 'zp-cur', y1: g.t, y2: g.b, visibility: 'hidden' });
var hit = g.node('rect', { 'class': 'zw-hit', x: g.l, y: 0, width: g.r - g.l, height: g.h });
function at(ev) {
var rect = g.svg.getBoundingClientRect();
var px = (ev.clientX - rect.left) / rect.width * g.w;
var best = 0;
rows.forEach(function (r, i) { if (Math.abs(x(r[0]) - px) < Math.abs(x(rows[best][0]) - px)) best = i });
var r = rows[best], total = sum(r), all = r[1] + r[2];
cur.setAttribute('x1', x(r[0])); cur.setAttribute('x2', x(r[0])); cur.setAttribute('visibility', 'visible');
var lines = [[null, ' всего на связи', fmt(total)]];
keys.forEach(function (c) { lines.push([color(c[0]), ' ' + c[1], fmt(r[c[2]])]) });
if (all) lines.push([null, ' на последней версии', pct(r[3] / all)]);
g.say(x(r[0]) / g.w * rect.width, day(r[0]) + ', ' + clock(r[0]), lines);
}
hit.addEventListener('pointermove', at);
hit.addEventListener('pointerdown', at);
hit.addEventListener('pointerleave', function () { cur.setAttribute('visibility', 'hidden'); g.tip.hidden = true });
}
/* обновления: контур — сколько программ получили разрешение, заливка —
сколько из них обновились и вышли на связь, красное — не удалось */
function drawUpdates(animate) {
var box = q('.zw-upd'), rows = ((S.series || {})[range]) || [];
var any = rows.some(function (r) { return (r[4] || 0) + (r[5] || 0) + (r[6] || 0) > 0 });
box.hidden = !any || rows.length < 2;
if (box.hidden) return;
var g = stage(box.querySelector('.zw-chart'), 48, animate);
var top = niceTop(Math.max.apply(null, rows.map(function (r) { return Math.max(r[4] || 0, (r[5] || 0) + (r[6] || 0)) })) || 1);
var slot = (g.r - g.l) / rows.length, bw = Math.max(2, Math.min(18, slot - 2));
function cx(i) { return g.l + slot * (i + 0.5) }
function y(v) { return g.b - v / top * (g.b - g.t) }
g.grid(top, short);
rows.forEach(function (r, i) {
var grant = r[4] || 0, done = r[5] || 0, fail = r[6] || 0;
if (!(grant + done + fail)) return;
var col = g.node('g', { 'class': 'zw-bcol zw-grow', style: '--i:' + i });
if (grant) g.node('rect', { 'class': 'zw-b-grant', x: cx(i) - bw / 2 + 0.5, width: Math.max(1, bw - 1), y: y(grant), height: Math.max(2, g.b - y(grant)) }, null, col);
if (done) g.node('rect', { 'class': 'zw-b-done', x: cx(i) - bw / 2, width: bw, y: Math.min(y(done), g.b - 2), height: Math.max(2, g.b - y(done)) }, null, col);
if (fail) g.node('rect', { 'class': 'zw-b-fail', x: cx(i) - bw / 2, width: bw, y: Math.min(y(done + fail), y(done) - 3), height: Math.max(3, y(done) - y(done + fail) - (done ? 2 : 0)) }, null, col);
});
g.times(0, rows.length - 1, function (i) { return cx(i) }, function (i) { var t = rows[Math.round(i)][0]; return range === '24h' ? clock(t) : day(t) });
var cur = g.node('line', { 'class': 'zp-cur', y1: g.t, y2: g.b, visibility: 'hidden' });
var hit = g.node('rect', { 'class': 'zw-hit', x: g.l, y: 0, width: g.r - g.l, height: g.h });
function at(ev) {
var rect = g.svg.getBoundingClientRect(), px = (ev.clientX - rect.left) / rect.width * g.w;
var i = Math.max(0, Math.min(rows.length - 1, Math.floor((px - g.l) / slot))), r = rows[i];
cur.setAttribute('x1', cx(i)); cur.setAttribute('x2', cx(i)); cur.setAttribute('visibility', 'visible');
g.say(cx(i) / g.w * rect.width, day(r[0]) + ', ' + clock(r[0]), [
[null, ' ' + plural(r[4] || 0, 'программа получила', 'программы получили', 'программ получили') + ' разрешение', fmt(r[4] || 0)],
[color('done'), ' обновились и вышли на связь', fmt(r[5] || 0)],
[color('fail'), ' обновиться не удалось', fmt(r[6] || 0)]
]);
}
hit.addEventListener('pointermove', at);
hit.addEventListener('pointerdown', at);
hit.addEventListener('pointerleave', function () { cur.setAttribute('visibility', 'hidden'); g.tip.hidden = true });
}
/* Таблицы: сортировка по щелчку на заголовке, полоска в ячейке — доля от
наибольшего. cells(item) -> [[текст, доля|null, класс|''], …] */
function compare(a, b) {
if (a == null || a === '') return 1;
if (b == null || b === '') return -1;
if (typeof a === 'string' && /^\d+(\.\d+)+$/.test(a) && typeof b === 'string') {
var x = a.split('.'), y = b.split('.');
for (var i = 0; i < Math.max(x.length, y.length); i++) { var d = (+x[i] || 0) - (+y[i] || 0); if (d) return d }
return 0;
}
return a < b ? -1 : a > b ? 1 : 0;
}
function fillTable(table, items, cells) {
var sort = sorts[table.getAttribute('data-t')], body = table.querySelector('tbody');
var rows = items.slice().sort(function (a, b) {
/* пустые значения всегда внизу, в какую сторону ни сортируй */
var x = a[sort[0]], y = b[sort[0]];
if (x == null || y == null) return compare(x, y);
return compare(x, y) * sort[1];
});
body.textContent = '';
rows.forEach(function (item) {
var tr = document.createElement('tr');
if (item.halted) tr.className = 'zw-row-halt';
cells(item).forEach(function (c) {
var td = document.createElement('td');
td.textContent = c[0];
if (c[1] != null) { td.className = 'zw-barc'; td.style.setProperty('--v', Math.max(0, Math.min(1, c[1])).toFixed(3)) }
if (c[2]) td.classList.add(c[2]);
tr.appendChild(td);
});
body.appendChild(tr);
});
table.querySelectorAll('th').forEach(function (th) {
var mine = th.getAttribute('data-s') === sort[0];
th.setAttribute('aria-sort', mine ? (sort[1] > 0 ? 'ascending' : 'descending') : 'none');
});
}
function drawDayTable() {
var days = S.days || [], most = Math.max.apply(null, days.map(function (d) { return d.total })) || 1;
fillTable(q('table[data-t="days"]'), days, function (d) {
return [[d.d.slice(8) + '.' + d.d.slice(5, 7), null, ''], [fmt(d.total), d.total / most, ''], [fmt(d.stable), null, ''], [fmt(d.dev), null, '']];
});
}
function drawHistory() {
var box = q('.zw-hist'), rolls = S.rollouts || [];
box.hidden = !rolls.length;
if (box.hidden) return;
fillTable(q('table[data-t="rolls"]'), rolls, function (r) {
var staged = (r.stages || []).length > 0;
var open = r.halted ? 'остановлена на ' + r.percent + '%' : (r.full_sec != null ? within(r.full_sec) : (staged ? 'идёт, ' + r.percent + '%' : '—'));
var got = r.granted || 0, came = r.confirmed || 0;
return [
[r.version + ' · ' + r.channel, null, 'zw-td-name'],
[day(r.released_at) + ', ' + clock(r.released_at), null, ''],
[reach(r, 'half_sec'), null, ''],
[reach(r, 'ninety_sec'), null, ''],
[open, null, r.halted ? 'zw-td-bad' : ''],
[got ? fmt(got) : '—', null, ''],
[got ? fmt(came) + ' · ' + pct(Math.min(1, came / got)) : (came ? fmt(came) : '—'), got ? came / got : null, ''],
[r.failed ? fmt(r.failed) : '—', null, r.failed ? 'zw-td-bad' : ''],
[r.took_median != null ? span(r.took_median) : '—', null, '']
];
});
}
function drawDays(animate) {
var box = q('.zw-days'), days = S.days || [];
var keys = CH.filter(function (c) { return on[c[0]] });
box.hidden = days.length < 2;
if (box.hidden) return;
drawDayTable();
var g = stage(box.querySelector('.zw-chart'), 48, animate);
function sum(d) { var s = 0; keys.forEach(function (c) { s += d[c[0]] }); return s }
var top = niceTop(Math.max.apply(null, days.map(sum)) || 1);
var slot = (g.r - g.l) / days.length, bw = Math.max(3, Math.min(34, slot - 2));
function y(v) { return g.b - v / top * (g.b - g.t) }
g.grid(top, short);
var every = Math.ceil(days.length / (g.w < 560 ? 4 : 10));
days.forEach(function (d, i) {
var cx = g.l + slot * (i + 0.5), col = g.node('g', { 'class': 'zw-bcol zw-grow', style: '--i:' + i }), base = 0;
keys.forEach(function (c) {
var v = d[c[0]];
if (v > 0) g.node('rect', { 'class': 'zw-b-' + c[0], x: cx - bw / 2, width: bw, y: y(base + v), height: Math.max(1, y(base) - y(base + v) - (base ? 2 : 0)) }, null, col);
base += v;
});
if (i % every === 0 || i === days.length - 1) g.node('text', { 'class': 'zp-xl', x: cx, y: g.h - 4 }, d.d.slice(8) + '.' + d.d.slice(5, 7));
var hit = g.node('rect', { 'class': 'zw-hit', x: cx - slot / 2, width: slot, y: 0, height: g.h, tabindex: 0 }, null, col);
function tell() {
col.classList.add('zw-hot');
var lines = [[null, ' ' + plural(d.total, 'пользователь', 'пользователя', 'пользователей') + ' всего', fmt(d.total)]];
keys.forEach(function (c) { lines.push([color(c[0]), ' ' + c[1], fmt(d[c[0]])]) });
g.say(cx / g.w * g.svg.getBoundingClientRect().width, d.d.slice(8) + ' ' + MONTHS[+d.d.slice(5, 7) - 1], lines);
}
function hush() { col.classList.remove('zw-hot'); g.tip.hidden = true }
hit.addEventListener('pointerenter', tell); hit.addEventListener('focus', tell);
hit.addEventListener('pointerleave', hush); hit.addEventListener('blur', hush);
});
}
function drawRollout(keepList, animate) {
var box = q('.zw-roll'), rolls = (S.rollouts || []).filter(function (r) { return (r.curve || []).length > 1 }).slice(0, LIST);
box.hidden = !rolls.length;
if (box.hidden) return;
var list = box.querySelector('.zw-rels');
function id(r) { return r.channel + '|' + r.version }
if (!rolls.some(function (r) { return id(r) === picked })) { picked = id(rolls[0]); zoom = -1 }
/* выпуск выбирается наведением. Список при этом не перестраивается:
иначе строка под курсором исчезала бы и наведение срывалось. */
if (!keepList) {
list.textContent = '';
rolls.forEach(function (r) {
var b = document.createElement('button');
b.type = 'button'; b.className = 'zw-rel' + (r.halted ? ' zw-rel-halt' : '');
b.setAttribute('data-id', id(r));
var name = document.createElement('b'); name.textContent = r.version + ' · ' + r.channel; b.appendChild(name);
var when = document.createElement('span'); when.textContent = 'вышла ' + day(r.released_at) + ' в ' + clock(r.released_at); b.appendChild(when);
var speed = document.createElement('span');
speed.textContent = r.halted ? 'раздача остановлена на ' + r.percent + '%' : 'половина — ' + reach(r, 'half_sec') + ', 90% — ' + reach(r, 'ninety_sec');
b.appendChild(speed);
function pick() { if (picked !== id(r)) { picked = id(r); zoom = -1; drawRollout(true, true) } }
b.addEventListener('pointerenter', pick);
b.addEventListener('focus', pick);
b.addEventListener('click', pick);
list.appendChild(b);
});
}
list.querySelectorAll('.zw-rel').forEach(function (b) { b.setAttribute('aria-pressed', b.getAttribute('data-id') === picked ? 'true' : 'false') });
var roll = rolls.filter(function (r) { return id(r) === picked })[0];
var all = roll.curve, end = all[all.length - 1][0];
/* отрезок по умолчанию — тот, где версия успела разойтись: у быстрого
выпуска первые минуты не сжимаются в точку на шкале в сутки */
if (zoom < 0) zoom = roll.ninety_sec != null && roll.ninety_sec < 400 && end > 600 ? 600 : (roll.ninety_sec != null && roll.ninety_sec < 2400 && end > 3600 ? 3600 : 0);
box.querySelectorAll('.zw-zoom button').forEach(function (b) {
var z = +b.getAttribute('data-z');
b.hidden = z > 0 && end <= z;
b.setAttribute('aria-pressed', z === zoom ? 'true' : 'false');
});
var pts = zoom ? all.filter(function (p) { return p[0] <= zoom }) : all;
if (pts.length < 2) pts = all;
q('.zw-roll-cap').textContent = 'доля программ канала ' + roll.channel + ' на версии ' + roll.version + ' после выхода';
var g = stage(box.querySelector('.zw-curve'), 48, animate);
var m1 = Math.max(60, zoom || pts[pts.length - 1][0]);
function x(m) { return g.l + m / m1 * (g.r - g.l) }
function y(v) { return g.b - v / 1000 * (g.b - g.t) }
g.grid(100, function (v) { return Math.round(v) + '%' });
g.node('line', { 'class': 'zw-mark', x1: g.l, x2: g.r, y1: y(900), y2: y(900) });
g.node('text', { 'class': 'zw-mark-l', x: g.r, y: y(900) - 5, 'text-anchor': 'end' }, '90%');
/* ступени раздачи: с этого мига версию могла получить такая доля программ */
var marks = (roll.stages || []).filter(function (st) { return st[0] <= m1 });
marks.forEach(function (st, k) {
var sx = x(st[0]), next = marks[k + 1];
g.node('line', { 'class': 'zw-stage-l zw-fade', x1: sx, x2: sx, y1: g.t, y2: g.b });
/* ступени, слипшиеся на шкале, подписывает последняя из них */
if (next && x(next[0]) - sx < 34) return;
g.node('text', { 'class': 'zw-stage-t zw-fade', x: sx + 5, y: g.t + 10 }, st[1] >= 100 ? 'всем' : st[1] + '%');
});
var line = pts.map(function (p) { return x(p[0]).toFixed(1) + ',' + y(p[1]).toFixed(1) });
g.node('polygon', { 'class': 'zw-fade zw-a-roll', points: line.concat([x(pts[pts.length - 1][0]).toFixed(1) + ',' + g.b, x(pts[0][0]).toFixed(1) + ',' + g.b]).join(' ') });
g.node('polyline', { 'class': 'zw-draw zw-l-roll', pathLength: 1, points: line.join(' ') });
for (var i = 0; i <= 4; i++) g.node('text', { 'class': 'zp-xl', x: Math.min(g.r - 22, Math.max(g.l + 18, x(m1 * i / 4))), y: g.h - 4 }, i ? span(Math.round(m1 * i / 4)) : 'выход');
var cur = g.node('line', { 'class': 'zp-cur', y1: g.t, y2: g.b, visibility: 'hidden' });
var dot = g.node('circle', { 'class': 'zw-dot', r: 4, visibility: 'hidden' });
var hit = g.node('rect', { 'class': 'zw-hit', x: g.l, y: 0, width: g.r - g.l, height: g.h });
function at(ev) {
var rect = g.svg.getBoundingClientRect(), px = (ev.clientX - rect.left) / rect.width * g.w, best = 0;
pts.forEach(function (p, k) { if (Math.abs(x(p[0]) - px) < Math.abs(x(pts[best][0]) - px)) best = k });
var p = pts[best], open = 0;
(roll.stages || []).forEach(function (st) { if (st[0] <= p[0]) open = st[1] });
cur.setAttribute('x1', x(p[0])); cur.setAttribute('x2', x(p[0])); cur.setAttribute('visibility', 'visible');
dot.setAttribute('cx', x(p[0])); dot.setAttribute('cy', y(p[1])); dot.setAttribute('visibility', 'visible');
var lines = [[color('stable'), ' программ на версии ' + roll.version, pct(p[1] / 1000)]];
if (open) lines.push([null, open >= 100 ? ' версия открыта всем' : ' программ могли её получить', open >= 100 ? '' : open + '%']);
g.say(x(p[0]) / g.w * rect.width, p[0] ? 'через ' + span(p[0]) + ' после выхода' : 'в момент выхода', lines);
}
hit.addEventListener('pointermove', at);
hit.addEventListener('pointerdown', at);
hit.addEventListener('pointerleave', function () { cur.setAttribute('visibility', 'hidden'); dot.setAttribute('visibility', 'hidden'); g.tip.hidden = true });
var facts = box.querySelector('.zw-facts'), parts = [];
if (roll.granted) {
parts.push('получили ' + fmt(roll.granted));
parts.push('обновились ' + fmt(roll.confirmed || 0) + ' (' + pct(Math.min(1, (roll.confirmed || 0) / roll.granted)) + ')');
parts.push('не вышло ' + fmt(roll.failed || 0));
}
if (roll.full_sec != null && (roll.stages || []).length > 1) parts.push('открыта всем ' + within(roll.full_sec));
if (roll.halted) parts.push('раздача остановлена' + (roll.reason ? ': ' + roll.reason : ''));
facts.hidden = !parts.length;
facts.textContent = parts.join(' · ');
facts.classList.toggle('zw-facts-halt', !!roll.halted);
drawTook(roll, animate);
}
/* сколько занимало обновление: столбцы по корзинам времени */
function drawTook(roll, animate) {
var box = q('.zw-took'), hist = roll.took || [], edges = S.took_buckets || [];
var total = hist.reduce(function (a, b) { return a + b }, 0);
box.hidden = !total || hist.length !== edges.length + 1;
if (box.hidden) return;
var g = stage(box.querySelector('.zw-chart'), 48, animate);
var top = niceTop(Math.max.apply(null, hist) || 1), slot = (g.r - g.l) / hist.length, bw = Math.min(72, slot * 0.62);
function y(v) { return g.b - v / top * (g.b - g.t) }
g.grid(top, short);
hist.forEach(function (n, i) {
var cx = g.l + slot * (i + 0.5), col = g.node('g', { 'class': 'zw-bcol zw-grow', style: '--i:' + i * 3 });
var label = i === 0 ? 'до ' + span(edges[0]) : (i === edges.length ? 'дольше ' + span(edges[i - 1]) : between(edges[i - 1], edges[i]));
if (n) g.node('rect', { 'class': 'zw-b-done', x: cx - bw / 2, width: bw, y: Math.min(y(n), g.b - 2), height: Math.max(2, g.b - y(n)) }, null, col);
g.node('text', { 'class': 'zp-xl', x: cx, y: g.h - 4 }, g.w < 560 && i % 2 ? '' : label);
var hit = g.node('rect', { 'class': 'zw-hit', x: cx - slot / 2, width: slot, y: 0, height: g.h, tabindex: 0 }, null, col);
function tell() {
col.classList.add('zw-hot');
g.say(cx / g.w * g.svg.getBoundingClientRect().width, 'обновление заняло ' + label, [
[color('done'), ' ' + plural(n, 'программа', 'программы', 'программ'), fmt(n)],
[null, ' от всех обновившихся', pct(n / total)]
]);
}
function hush() { col.classList.remove('zw-hot'); g.tip.hidden = true }
hit.addEventListener('pointerenter', tell); hit.addEventListener('focus', tell);
hit.addEventListener('pointerleave', hush); hit.addEventListener('blur', hush);
});
}
/* Раздача по ступеням: лестница «5% → 25% → все» с тем, что уже пройдено */
function drawStage() {
var box = q('.zw-stage'), rows = box.querySelector('.zw-stage-rows'), now = S.updated || Date.now() / 1000;
rows.textContent = '';
CH.forEach(function (c) {
var r = (S.rollout || {})[c[0]];
if (!r || (r.ladder || []).length < 2) return;
/* спокойно розданная версия через сутки с доски уходит; остановленная — висит */
if (!r.halted && r.percent >= 100 && now - (r.announced || 0) > 86400) return;
var reached = {};
(r.stages || []).forEach(function (st) { if (!(st[1] in reached)) reached[st[1]] = st[0] });
var row = document.createElement('div'); row.className = 'zw-st' + (r.halted ? ' zw-st-halt' : '');
var name = document.createElement('b'); name.textContent = r.version + ' · ' + c[0]; row.appendChild(name);
var steps = document.createElement('ol'); steps.className = 'zw-steps';
r.ladder.forEach(function (p) {
var li = document.createElement('li'), cur = p === r.percent && (p < 100 || r.halted);
var state = cur ? (r.halted ? 'halt' : 'now') : (p in reached ? 'done' : 'next');
li.className = 'zw-step zw-step-' + state;
var big = document.createElement('span'); big.textContent = p >= 100 ? 'все' : p + '%'; li.appendChild(big);
var note = document.createElement('small');
note.textContent = state === 'halt' ? 'остановлена' : state === 'now' ? 'идёт сейчас' : state === 'next' ? 'ждёт' : (reached[p] < 1 ? 'с выхода' : 'через ' + span(reached[p]));
li.appendChild(note);
steps.appendChild(li);
});
row.appendChild(steps);
var sum = document.createElement('p');
sum.textContent = r.halted
? 'Раздача остановлена' + (r.reason ? ': ' + r.reason : '') + '. Остальным программам эта версия сама не придёт.'
: 'получили ' + fmt(r.granted || 0) + ' · обновились и вышли на связь ' + fmt(r.confirmed || 0) + ' · не вышло ' + fmt(r.failed || 0);
row.appendChild(sum);
rows.appendChild(row);
});
box.hidden = !rows.children.length;
}
function drawCharts(animate) { drawOnline(animate); drawUpdates(animate); drawDays(animate); drawRollout(false, animate); drawHistory() }
function render(s) {
if (!s || !s.online) return;
var online = s.online.total || 0;
var users = (s.users_today && s.users_today.total) || 0;
var none = !online && !users && !(s.days || []).length;
/* пусто: по умолчанию раздела просто нет; если программу выбрали
сами — честно говорим, что у неё чисел ещё нет */
if (none && !chosen) return;
live.classList.toggle('zw-none', none);
q('.zw-empty').hidden = !none;
put('scope', PRODUCTS[product].scope);
if (none) { live.hidden = false; return }
/* итог прежнего вида: время расхождения было в минутах */
(s.rollouts || []).forEach(function (r) {
if (r.half_sec !== undefined || r.half_min === undefined) return;
r.half_sec = r.half_min == null ? null : r.half_min * 60;
r.ninety_sec = r.ninety_min == null ? null : r.ninety_min * 60;
r.curve = (r.curve || []).map(function (p) { return [p[0] * 60, p[1]] });
});
S = s;
live.hidden = false;
var el = cell('online');
if (first && online > 20 && !still) countUp(el, online, 900);
else el.textContent = fmt(online);
put('users', fmt(users));
put('users-word', plural(users, 'пользователь', 'пользователя', 'пользователей'));
var latest = s.latest || {}, onLatest = 0;
(s.online.versions || []).forEach(function (v) { if (v.channel && latest[v.channel] === v.version) onLatest += v.count });
put('share', online ? pct(onLatest / online) : '—');
show('latest-dev', !!latest.dev); put('latest-dev', latest.dev || '—');
show('latest-stable', !!latest.stable); put('latest-stable', latest.stable || '—');
var roll = null, timed = null;
(s.rollouts || []).forEach(function (r) {
if (!roll && r.half_sec != null) roll = r;
if (!timed && r.took_median != null) timed = r;
});
show('half', !!roll);
if (roll) {
put('half-label', roll.version + ' дошла до половины программ');
put('half', reach(roll, 'half_sec'));
}
var queued = ((s.queued && s.queued.dev) || 0) + ((s.queued && s.queued.stable) || 0);
show('queued', queued > 0); put('queued', fmt(queued));
/* сервер сам говорит, когда очередь дойдёт: он знает свой канал и вес установщика */
put('queued-label', 'ждут очереди на скачивание' + (queued > 0 && s.queue_eta ? ', дойдёт примерно за ' + span(Math.max(60, s.queue_eta)) : ''));
var peak = s.peak_24h;
show('peak', !!peak && peak.total > online);
if (peak) { put('peak', fmt(peak.total)); put('peak-label', 'пик за сутки, в ' + clock(peak.t)) }
var upd = s.updates_24h || { grants: s.grants_24h || 0, done: 0, failed: 0 };
show('done', upd.done > 0 || upd.grants > 0);
put('done', fmt(upd.done || upd.grants));
put('done-label', upd.done ? 'программ обновилось за сутки' + (upd.grants ? ', из ' + fmt(upd.grants) + ' получивших разрешение' : '') : 'обновлений выдано за сутки');
show('took', !!timed);
if (timed) put('took', span(timed.took_median));
show('failed', upd.failed > 0); put('failed', fmt(upd.failed || 0));
scene.update(s);
drawStage();
drawVersions();
drawActivity();
drawTail();
drawWhy();
/* графики появляются плавно один раз — когда раздел на экране */
drawCharts(first && seen);
first = false;
}
/* сортировка таблиц: заголовок — кнопка; повторный щелчок меняет направление */
live.querySelectorAll('.zw-tbl th[data-s]').forEach(function (th) {
var b = document.createElement('button');
b.type = 'button'; b.textContent = th.textContent;
th.textContent = ''; th.appendChild(b);
b.addEventListener('click', function () {
var table = th.closest('table'), name = table.getAttribute('data-t'), key = th.getAttribute('data-s');
sorts[name] = [key, sorts[name][0] === key ? -sorts[name][1] : -1];
if (!S) return;
if (name === 'days') drawDayTable(); else drawHistory();
});
});
live.querySelectorAll('.zw-zoom button').forEach(function (b) {
b.addEventListener('click', function () { zoom = +b.getAttribute('data-z'); if (S) drawRollout(true, true) });
});
if (window.IntersectionObserver) {
var io = new IntersectionObserver(function (es) {
if (seen || !es.some(function (e) { return e.isIntersecting })) return;
seen = true; io.disconnect();
if (S) drawCharts(true);
}, { threshold: 0.12 });
io.observe(live);
} else seen = true;
live.querySelectorAll('.zw-ctl .zp-range button').forEach(function (b) {
b.addEventListener('click', function () {
range = b.getAttribute('data-r');
live.querySelectorAll('.zw-ctl .zp-range button').forEach(function (x) { x.setAttribute('aria-pressed', x === b ? 'true' : 'false') });
if (S) { drawOnline(true); drawUpdates(true) }
});
});
live.querySelectorAll('.zw-legend .zp-lg').forEach(function (b) {
b.addEventListener('click', function () {
var key = b.getAttribute('data-c');
/* последний включённый канал выключить нельзя: пустой график ничего не говорит */
if (on[key] && !CH.some(function (c) { return c[0] !== key && on[c[0]] })) return;
on[key] = !on[key];
b.setAttribute('aria-pressed', on[key] ? 'true' : 'false');
if (S) { drawOnline(true); drawDays(true) }
});
});
var resizing = 0;
window.addEventListener('resize', function () {
clearTimeout(resizing);
resizing = setTimeout(function () { if (S) drawCharts() }, 150);
});
function load() {
if (document.hidden) return;
var asked = product;
fetch(PRODUCTS[asked].url + '?m=' + Math.floor(Date.now() / 60000))
.then(function (r) { if (!r.ok) throw 0; return r.json() })
/* ответ про программу, с которой уже ушли, не показываем */
.then(function (s) { if (asked === product) render(s) })
/* итога нет вовсе: у выбранной программы это то же «чисел ещё нет» */
.catch(function () { if (asked === product && chosen && !S) render({ online: { total: 0 } }) });
}
live.querySelectorAll('.zw-prod button').forEach(function (b) {
/* у программы без своего итога кнопки нет вовсе */
if (!PRODUCTS[b.getAttribute('data-p')].url) { b.hidden = true; return }
b.addEventListener('click', function () {
var next = b.getAttribute('data-p');
if (next === product) return;
product = next; chosen = true;
live.querySelectorAll('.zw-prod button').forEach(function (x) { x.setAttribute('aria-pressed', x === b ? 'true' : 'false') });
/* графики новой программы появляются заново, выбор выпуска и отрезка сбрасывается */
S = null; first = true; picked = ''; zoom = -1;
scene.reset();
load();
});
});
load();
setInterval(load, 60000);
document.addEventListener('visibilitychange', function () { if (!document.hidden) load() });
})();
/* ── Пауза анимаций вне экрана ──────────────────────────────────────── */
(function () {
if (!window.IntersectionObserver) return;
var blocks = document.querySelectorAll('.zpm-home > section, .zpm-home > div:not(.zx), #zpm-duel, #zg-packets');
var io = new IntersectionObserver(function (es) {
es.forEach(function (e) { e.target.classList.toggle('zoff', !e.isIntersecting) });
}, { rootMargin: '150px 0px' });
blocks.forEach(function (b) { io.observe(b) });
})();
})();