PageSourceSearch

https://im.rusfond.ru/rf2017-assets/scripts/app-landing/landing.js…EdS-Zz9WYkTYfOEv3VSfNYzIRVElWUXZd9zDbfFpDzs

js rusfond.ru collected 2026-10-02 02:38:07 UTC 68,439 bytes, 1,246 lines download raw bytes

1// Логика лендинга /app: 3D-сцена героя, живая лента писем, график отчётов,
2// раскрывающиеся карточки наград и видео, переключатель темы.
3// Перенесено из прототипа design/app-landing/prototype.html без изменения поведения.
4
5// Награды и видео лежат отдельным редактируемым файлом (landing-static.js),
6// всё остальное сервер кладёт в RF_SNAPSHOT прямо в разметке. Сводим в один объект,
7// чтобы ниже по коду источник данных был один.
8(function () {
9  var S = window.RF_SNAPSHOT = window.RF_SNAPSHOT || {};
10  var T = window.RF_APP_STATIC || {};
11  if (!S.awards) S.awards = T.awards || [];
12  if (!S.videos) S.videos = T.videos || [];
13})();
14
15(function(){
16  var root=document.getElementById('mock-d');
17
18  /* ---------- год и статусы фич ----------
19     Статусы вынесены в данные: в бою их отдаёт /apk/app-latest.json рядом с версией,
20     так что после релиза достаточно поправить json — вёрстку трогать не нужно. */
21  (function(){
22    var y=document.getElementById('d-year');
23    if(y) y.textContent=new Date().getFullYear();
24
25    /* Статусы приходят с сервера из /apk/app-latest.json. Зашитого запасного набора
26       здесь нет намеренно: он молча устаревает после релиза и врёт человеку. */
27    var features=(window.RF_SNAPSHOT&&RF_SNAPSHOT.features)||{};
28    var WORDS={
29      released:{ios:'Работает в App Store',    android:'Работает в Google Play и RuStore', cls:'on'},
30      testing: {ios:'iOS — в тестировании',    android:'Android — в тестировании',         cls:'test'},
31      planned: {ios:'iOS — в работе',          android:'Android — в работе',               cls:'soon'}
32    };
33    root.querySelectorAll('.feat-state[data-feat]').forEach(function(el){
34      var st=(features[el.dataset.feat]||{})[el.dataset.platform];
35      var w=WORDS[st];
36      if(!w){ el.remove(); return; }        /* статуса нет — не выдумываем, просто не показываем */
37      el.textContent=w[el.dataset.platform];
38      el.className='feat-state '+w.cls;
39    });
40  })();
41
42  /* ---------- кнопка «наверх» ---------- */
43  (function(){
44    var btn=document.getElementById('d-totop');
45    if(!btn) return;
46    function upd(){
47      var on=scrollY>600;
48      btn.dataset.on=on?'1':'0';
49      btn.hidden=!on&&btn.dataset.on!=='1'?false:false;   /* держим в потоке: скрываем прозрачностью */
50    }
51    btn.hidden=false; upd();
52    addEventListener('scroll',upd,{passive:true});
53    btn.addEventListener('click',function(){
54      scrollTo({top:0,behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'auto':'smooth'});
55    });
56  })();
57
58  /* ---------- демонстрация отложенного пожертвования ----------
59     Тексты взяты из docs/deferred_donations_texts.md: важно, что деньги не списаны. */
60  (function(){
61    var txt=document.getElementById('d-demo');
62    if(!txt) return;
63    var demo=txt.closest('.demo'), bar=demo.querySelector('.demo-bar i');
64    var steps=[
65      {t:'Нет связи с сервером',                       cls:'',      w:'0%'},
66      {t:'Сохранено на этом телефоне · 500 ₽',         cls:'saved', w:'45%'},
67      {t:'Деньги не списаны, напомним при связи',      cls:'saved', w:'70%'},
68      {t:'Связь появилась — пожертвование отправлено', cls:'sent',  w:'100%'}
69    ];
70    var i=0, calm=matchMedia('(prefers-reduced-motion: reduce)').matches;
71    function show(){
72      var st=steps[i];
73      txt.textContent=st.t;
74      demo.className='demo'+(st.cls?' '+st.cls:'');
75      bar.style.width=st.w;
76      i=(i+1)%steps.length;
77    }
78    show();
79    if(calm) return;
80    var timer=null;
81    var io=new IntersectionObserver(function(es){
82      if(es[0].isIntersecting){ if(!timer) timer=setInterval(show,2600); }
83      else if(timer){ clearInterval(timer); timer=null; }
84    },{threshold:.4});
85    io.observe(demo);
86  })();
87
88  /* ---------- награды: карточка разворачивается вниз ---------- */
89  (function(){
90    var host=document.getElementById('d-awards'), list=(window.RF_SNAPSHOT||{}).awards||[];
91    if(!host) return;
92    host.innerHTML=list.map(function(a,i){
93      return '<article class="aw" data-open="0" tabindex="0" role="button" aria-expanded="false">'+
94        '<div class="cover"><img src="'+a.c+'" alt=""></div>'+
95        '<div class="head"><span class="yr">'+a.y+'</span><h3>'+a.t+'</h3><p>'+a.n+'</p>'+
96        '<span class="more">+</span></div>'+
97        '<div class="fold"><div><p>'+a.txt+'</p>'+
98        '<a href="'+a.u+'" target="_blank" rel="noopener">Читать новость <span>→</span></a></div></div>'+
99      '</article>';
100    }).join('');
101    host.addEventListener('click',function(e){
102      if(e.target.closest('a')) return;               /* ссылка внутри работает как ссылка */
103      var card=e.target.closest('.aw'); if(!card) return;
104      var open=card.dataset.open!=='1';
105      card.dataset.open=open?'1':'0';
106      card.setAttribute('aria-expanded',open?'true':'false');
107    });
108    host.addEventListener('keydown',function(e){
109      if(e.key!=='Enter'&&e.key!==' ') return;
110      var card=e.target.closest('.aw'); if(!card) return;
111      e.preventDefault(); card.click();
112    });
113  })();
114
115  /* ---------- видео: карточка разворачивается и подгружает плеер ---------- */
116  (function(){
117    var host=document.getElementById('d-videos'), list=(window.RF_SNAPSHOT||{}).videos||[];
118    if(!host) return;
119    var PLAY='<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>';
120    host.innerHTML=list.map(function(v){
121      return '<article class="aw vid" data-open="0" data-id="'+v.id+'" tabindex="0" role="button" aria-expanded="false">'+
122        '<div class="cover"><img src="'+v.c+'" alt="" loading="lazy">'+
123          '<span class="play" aria-hidden="true">'+PLAY+'</span><span class="dur">'+v.d+'</span></div>'+
124        '<div class="head"><span class="yr">Дзен</span><h3>'+v.t+'</h3><p>'+v.n+'</p>'+
125        '<span class="more">+</span></div>'+
126        '<div class="fold"><div><div class="frame"></div><p>'+v.txt+'</p>'+
127        '<a href="https://dzen.ru/video/watch/'+v.w+'" target="_blank" rel="noopener">Открыть на Дзене <span>→</span></a>'+
128        '</div></div></article>';
129    }).join('');
130    function set(card,open){
131      card.dataset.open=open?'1':'0';
132      card.setAttribute('aria-expanded',open?'true':'false');
133      var frame=card.querySelector('.frame');
134      if(open){
135        if(!frame.firstChild){
136          var f=document.createElement('iframe');
137          f.src='https://dzen.ru/embed/'+card.dataset.id+'?from_block=partner&from=zen&mute=0&autoplay=1&tv=0';
138          f.setAttribute('allow','autoplay; fullscreen; accelerometer; gyroscope; picture-in-picture; encrypted-media');
139          f.setAttribute('allowfullscreen','');
140          f.setAttribute('scrolling','no');
141          f.setAttribute('title',card.querySelector('h3').textContent);
142          frame.appendChild(f);
143        }
144      } else {
145        frame.innerHTML='';                       /* закрыли — звук замолкает */
146      }
147    }
148    host.addEventListener('click',function(e){
149      if(e.target.closest('a')||e.target.closest('iframe')) return;
150      var card=e.target.closest('.vid'); if(!card) return;
151      var open=card.dataset.open!=='1';
152      if(open) Array.prototype.forEach.call(host.querySelectorAll('.vid[data-open="1"]'),function(c){ set(c,false); });
153      set(card,open);
154    });
155    host.addEventListener('keydown',function(e){
156      if(e.key!=='Enter'&&e.key!==' ') return;
157      var card=e.target.closest('.vid'); if(!card) return;
158      e.preventDefault(); card.click();
159    });
160  })();
161
162  /* ---------- цели Яндекс.Метрики ----------
163     Раньше это жило в jQuery-бандле MobileAppLanding/main.js, который отсюда убран.
164     Счётчик поднимает компонент CountersTop, здесь только отправка цели. */
165  (function(){
166    function goal(name){
167      try{
168        if(window.yaCounter33497798) window.yaCounter33497798.reachGoal(name);
169        else if(window.ym) window.ym(33497798,'reachGoal',name);
170      }catch(e){}
171    }
172    function ga(cat,act){ try{ if(window.ga) window.ga('send','event',cat,act); }catch(e){} }
173    document.addEventListener('click',function(e){
174      var el=e.target.closest && e.target.closest('.js-metrika,.js-ga');
175      if(!el) return;
176      if(el.dataset.metrikaGoal) goal(el.dataset.metrikaGoal);
177      if(el.dataset.gaCategory) ga(el.dataset.gaCategory,el.dataset.gaAction);
178    });
179  })();
180
181  /* ---------- график на весь экран ---------- */
182  (function(){
183    var btn=document.getElementById('d-fullscreen');
184    if(!btn) return;
185    var box=btn.closest('.chart');
186    btn.addEventListener('click',function(){
187      if(document.fullscreenElement){ document.exitFullscreen(); return; }
188      if(box.requestFullscreen) box.requestFullscreen();
189      else if(box.webkitRequestFullscreen) box.webkitRequestFullscreen();
190    });
191    document.addEventListener('fullscreenchange',function(){
192      btn.firstChild.nodeValue=document.fullscreenElement?'Свернуть ':'Открыть на полный экран ';
193    });
194  })();
195
196  /* ---------- «Скачать» в шапке ----------
197     Если человек и так наверху, прокручивать некуда — кнопка кажется сломанной.
198     Тогда вместо прокрутки один раз подсвечиваем сами кнопки магазинов. */
199  (function(){
200    var stores=document.getElementById('d-stores');
201    if(!stores) return;
202    root.querySelectorAll('[data-scroll-to-stores]').forEach(function(link){
203      link.addEventListener('click',function(e){
204        var box=stores.getBoundingClientRect();
205        /* «Видно» — это видно целиком и не из-под шапки: если верхний ряд кнопок
206           накрыт липкой навигацией, честнее домотать страницу, чем моргать подсветкой. */
207        var navH=(root.querySelector('.nav')||{offsetHeight:0}).offsetHeight;
208        var visible=box.top>=navH && box.bottom<=innerHeight;
209        if(!visible) return;                     /* обычная прокрутка по якорю */
210        e.preventDefault();
211        stores.classList.remove('look');
212        void stores.offsetWidth;                 /* перезапуск анимации */
213        stores.classList.add('look');
214      });
215    });
216    stores.addEventListener('animationend',function(){ stores.classList.remove('look'); },true);
217  })();
218
219  /* ---------- подписка: проверка адреса ----------
220     Прежняя страница /app показывала ошибку прямо на кнопке и не давала отправить
221     форму с мусором. Тут то же самое, только без jQuery. Регулярка мягче старой:
222     та не пропускала адреса с дефисом в домене (my-mail.ru), а это обычные адреса. */
223  (function(){
224    var form=document.getElementById('subscribtion_form');
225    if(!form) return;
226    var email=form.querySelector('.js-subs-email'), btn=form.querySelector('.js-subs-button');
227    if(!email||!btn) return;
228
229    var RE=/^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}$/;
230
231    function check(){
232      var v=email.value.trim();
233      var state = !v ? 'idle' : (RE.test(v) ? 'ok' : 'bad');
234      btn.textContent=btn.dataset[state];
235      btn.disabled=(state!=='ok');
236      btn.className='js-subs-button'+(state==='ok'?'':' '+state);
237      email.classList.toggle('bad',state==='bad');
238    }
239
240    email.addEventListener('input',check);
241    email.addEventListener('change',check);   /* подстановка из автозаполнения */
242    check();                                  /* браузер мог заполнить поле заранее */
243  })();
244
245  /* ---------- мобильное меню ---------- */
246  (function(){
247    var btn=document.getElementById('d-burger'), sheet=document.getElementById('d-menu');
248    if(!btn||!sheet) return;
249    function set(open){
250      btn.setAttribute('aria-expanded',open?'true':'false');
251      sheet.dataset.open=open?'1':'0';
252      sheet.hidden=!open;
253    }
254    set(false);
255    btn.addEventListener('click',function(){ set(btn.getAttribute('aria-expanded')!=='true'); });
256    sheet.addEventListener('click',function(e){ if(e.target.tagName==='A') set(false); });
257    addEventListener('keydown',function(e){ if(e.key==='Escape') set(false); });
258    /* ушли с узкого экрана — панель больше не нужна */
259    matchMedia('(min-width:701px)').addEventListener('change',function(e){ if(e.matches) set(false); });
260  })();
261
262  /* ---------- версия APK ----------
263     В бою это делает apkVersion.js: fetch('/apk/app-latest.json') и те же классы js-apk-*.
264     Здесь манифест вшит, чтобы кнопка показывала настоящую сборку. */
265  (function(){
266    var data={versionName:'4.3.50465',date:'2026-08-19',url:'https://im.rusfond.ru/pics/site/apk/Rusfond-latest.apk'};
267    var MON=['января','февраля','марта','апреля','мая','июня','июля','августа','сентября','октября','ноября','декабря'];
268    root.querySelectorAll('.js-apk-link').forEach(function(link){
269      link.setAttribute('href',data.url);
270      link.setAttribute('download','Rusfond-'+data.versionName+'.apk');
271      var v=link.querySelector('.js-apk-version');
272      if(v) v.textContent=data.versionName;
273    });
274    var d=new Date(data.date+'T00:00:00');
275    root.querySelectorAll('.js-apk-note').forEach(function(n){
276      n.textContent='Файл для Android от '+d.getDate()+' '+MON[d.getMonth()]+' '+d.getFullYear()+
277                    ' года — если недоступны магазины приложений';
278    });
279  })();
280
281  /* ---------- счётчик писем: правильное склонение ---------- */
282  (function(){
283    var el=document.getElementById('d-count');
284    if(!el) return;
285    var n=(window.RF_SNAPSHOT&&RF_SNAPSHOT.total)||kids.length;
286    var t=n%100, u=n%10, word;
287    if(t>10&&t<20) word='писем';
288    else if(u===1) word='письмо';
289    else if(u>=2&&u<=4) word='письма';
290    else word='писем';
291    el.textContent='Сегодня в приложениях '+n+' '+word+' с просьбой о помощи';
292  })();
293
294  /* ---------- часы в статус-баре: настоящее время ---------- */
295  (function(){
296    var el=document.getElementById('d-clock');
297    if(!el) return;
298    function tick(){
299      var d=new Date();
300      el.textContent=d.getHours()+':'+String(d.getMinutes()).padStart(2,'0');
301    }
302    tick();
303    setTimeout(function(){ tick(); setInterval(tick,60000); }, (60-new Date().getSeconds())*1000);
304  })();
305
306  /* ---------- прокрутка перетаскиванием ----------
307     Живой макет должен вести себя как экран: список тянется мышью, полки — вбок. */
308  function dragScroll(el,axis){
309    var down=false,moved=false,sx=0,sy=0,l0=0,t0=0;
310    el.style.cursor='grab';
311    /* Внутри полок лежат ссылки с картинками, и браузер на них запускает собственное
312       перетаскивание: курсор уводит призрак ссылки, наш pointermove не приходит, а по
313       отпусканию срабатывает переход. Поэтому нативный drag глушим. */
314    el.addEventListener('dragstart',function(e){ e.preventDefault(); });
315
316    el.addEventListener('pointerdown',function(e){
317      if(e.button) return;
318      down=true; moved=false; sx=e.clientX; sy=e.clientY;
319      l0=el.scrollLeft; t0=el.scrollTop;
320      el.style.cursor='grabbing'; el.style.userSelect='none';
321    });
322    addEventListener('pointermove',function(e){
323      if(!down) return;
324      var dx=e.clientX-sx, dy=e.clientY-sy;
325      if(Math.abs(dx)+Math.abs(dy)>4) moved=true;
326      if(axis!=='y') el.scrollLeft=l0-dx;
327      if(axis!=='x') el.scrollTop=t0-dy;
328    },{passive:true});
329    addEventListener('pointerup',function(){
330      if(!down) return;
331      down=false; el.style.cursor='grab'; el.style.userSelect='';
332    });
333    /* потянули — значит листали, а не открывали карточку */
334    el.addEventListener('click',function(e){
335      if(moved){ e.preventDefault(); e.stopPropagation(); }
336    },true);
337  }
338
339  /* ---------- тема: солнце и луна ----------
340     Стартуем от выбора зрителя (стамп на документе или системная настройка),
341     дальше запоминаем ручной выбор. */
342  var themeHooks=[];
343  function currentTheme(){ return root.getAttribute('data-theme')||'dark'; }
344  (function(){
345    var saved=null;
346    try{ saved=localStorage.getItem('rf-app-theme'); }catch(e){}
347    var host=document.documentElement;
348
349    /* Страница по умолчанию тёмная — так она выглядит дороже, и это решение,
350       а не следование за системой. Дальше человек выбирает солнцем или луной,
351       и выбор запоминается. Стамп на документе уважаем: его ставит окружение,
352       внутри которого страница может быть открыта. */
353    function auto(){
354      var stamp=host.getAttribute('data-theme');
355      if(stamp==='light'||stamp==='dark') return stamp;
356      return 'dark';
357    }
358    function apply(mode,announce){
359      root.setAttribute('data-theme',mode==='light'?'light':'dark');
360      if(announce) themeHooks.forEach(function(fn){ fn(currentTheme()); });
361    }
362    /* Сохранённый выбор, совпавший с темой по умолчанию, хранить незачем. */
363    if(saved===auto()){ saved=null; try{ localStorage.removeItem('rf-app-theme'); }catch(e){} }
364
365    apply(saved||auto(),false);
366
367    function follow(){ if(!saved) apply(auto(),true); }
368    if(window.MutationObserver){
369      new MutationObserver(follow).observe(host,{attributes:true,attributeFilter:['data-theme']});
370    }
371
372    var btn=document.getElementById('d-theme');
373    btn.addEventListener('click',function(){
374      var next=currentTheme()==='light'?'dark':'light';
375      if(next===auto()){                       /* вернулись к теме по умолчанию — помнить нечего */
376        saved=null; try{ localStorage.removeItem('rf-app-theme'); }catch(e){}
377      } else {
378        saved=next; try{ localStorage.setItem('rf-app-theme',next); }catch(e){}
379      }
380      apply(next,true);
381    });
382    })();
383
384  /* ---------- живая лента писем ----------
385     В бою сюда приходит /api/please-help-destitutes (тот же эндпоинт, что и сейчас),
386     здесь — реальные письма с сайта, чтобы вёрстка проверялась на настоящих длинах строк. */
387  var SNAP=window.RF_SNAPSHOT||{kids:[],programs:[]};
388  var kids=SNAP.kids.map(function(k){
389    return {n:(k.first+' '+k.last).trim(), d:k.diag, s:k.need, u:k.url, p:k.photo, r:k.region,
390            deferred:!!k.deferred};
391  });
392  /* благодарности: mobile.rusfond.ru/api/v1/destitutes/parent-gratitudes/videos,
393     обложка — og:image страницы плеера, тем же путём, что берёт приложение */
394  var thanks=(SNAP.thanks||[]).map(function(t){ return {n:t.n,u:t.u,c:t.cover,note:t.note}; });
395  /* программы: mobile.rusfond.ru/api/v1/programs — те же обложки и ссылки, что в приложении */
396  var programs=(SNAP.programsV2||[]).map(function(p2){
397    return {t:p2.t, d:p2.d, u:p2.u, c:p2.c};
398  });
399  function esc(t){return t.replace(/[&<>]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;'}[c];});}
400  /* Писем в ленте столько же, сколько наÐ
400·Ñ‹Ð²Ð°ÐµÑ‚ счётчик в шапке, — то есть все открытые.
401     Фотографии за пределами первых карточек грузим лениво: список вертикальный, до них
402     ещё надо доскроллить, и в отличие от горизонтальной полки благодарностей ленивая
403     загрузка здесь ничего не ломает. */
404  var EAGER_PHOTOS=6;
405  function kidCard(k,i){
406    var lazy=i>=EAGER_PHOTOS? ' loading="lazy" decoding="async"' : '';
407    var face=k.p? '<img src="'+k.p+'" alt=""'+lazy+'>' : esc(k.n.charAt(0));
408    return '<a class="kid" href="'+k.u+'" target="_blank" rel="noopener" title="'+esc(k.n)+', '+esc(k.r)+'">'+
409      /* У тех, кого назвал баннер отложенного пожертвования, вместо галочки часы:
410         платёж ждёт подтверждения, а не завершён. Кружок и цвет те же — в приложении
411         отдельной раскраски у этой метки нет, а страница показывает то, что есть.
412         Циферблат равен затемнённому кружку под ним, а не галочке: маленький
413         дискообразный циферблат на плитке в 22 точки просто не читался. */
414      '<span class="ph2">'+face+'<em>'+
415        (k.deferred
416          ? '<svg class="cl" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="6.1"/><path d="M7 3.8v3.2l2.3 1.5"/></svg>'
417          : '<svg viewBox="0 0 14 14" aria-hidden="true"><path d="M3.2 7.4 5.9 10 10.8 4.2"/></svg>')+
418      '</em></span>'+
419      '<span class="info">'+
420        '<span class="nm">'+esc(k.n)+'</span>'+
421        '<span class="dg">'+esc(k.d)+'</span>'+
422        '<span class="need">Не хватает <b>'+esc(k.s)+'</b></span>'+
423      '</span>'+
424      '<span class="chev">›</span></a>';
425  }
426  var feed=document.getElementById('d-feed'),html='';
427  kids.forEach(function(k,i){
428    html+=kidCard(k,i);
429    if(i===2){
430      html+='<div class="shelf"><h5>Благодарности</h5><div class="rail">'+
431        thanks.map(function(t){
432          /* Кадры из видео тяжёлые (100–200 КБ), а страница в этот момент занята
433             3D-сценой, и Safari откладывает их загрузку: плитка стоит пустой, пока
434             человек её не тронет. Поэтому грузим все сразу и с высоким приоритетом —
435             ленивая загрузка в горизонтальной полке делает залипание только заметнее.
436             Битую картинку убираем: под ней остаётся тёмная плитка с именем ребёнка. */
437          var bg=t.c? '<img src="'+t.c+'" alt="" decoding="async" fetchpriority="high"'+
438                      ' onerror="this.remove()">' : '';
439          return '<a class="thx" href="'+t.u+'" target="_blank" rel="noopener">'+bg+
440                 '<b><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 7.5 17 12l-8 4.5z"/></svg></b>'+
441                 '<span class="who">'+esc(t.n)+'</span></a>';}).join('')+'</div></div>';
442    }
443    if(i===5){
444      html+='<div class="shelf"><h5>Программы</h5><div class="rail">'+
445        programs.map(function(p){
446          var cov=p.c? '<img src="'+p.c+'" alt="">' : '';
447          return '<a class="prg" href="'+p.u+'" target="_blank" rel="noopener"><span class="im">'+cov+'</span>'+
448                 '<span class="tx"><b>'+esc(p.t)+'</b><span>'+esc(p.d)+'</span></span></a>';
449        }).join('')+'</div></div>';
450    }
451  });
452  feed.innerHTML=html;
453  dragScroll(feed,'y');
454  [].slice.call(root.querySelectorAll('.shelf .rail')).forEach(function(r){ dragScroll(r,'x'); });
455  /* суммы в ленте тоже подгоняются, см. блок подгонки ниже */
456
457  /* ---------- цвет ведёт по разделам ---------- */
458  var tones={indigo:'108,123,255',mint:'53,214,164',amber:'255,194,92',violet:'169,139,255'};
459  var links=[].slice.call(root.querySelectorAll('.nav a.l'));
460  var secs=[].slice.call(root.querySelectorAll('section[data-accent]'));
461  function paint(sec){
462    var rgb=tones[sec.dataset.accent]||tones.indigo;
463    root.style.setProperty('--a','rgb('+rgb+')');
464    root.style.setProperty('--a-soft','rgba('+rgb+',.17)');
465    links.forEach(function(l){l.dataset.on=(l.dataset.sec===sec.id)?'1':'0';});
466  }
467  if('IntersectionObserver' in window){
468    var io=new IntersectionObserver(function(es){
469      es.forEach(function(e){ if(e.isIntersecting) paint(e.target); });
470    },{rootMargin:'-45% 0px -45% 0px'});
471    secs.forEach(function(s){io.observe(s);});
472  }
473
474  /* прогресс чтения — фолбэк для браузеров без scroll-timeline */
475  var prog=document.getElementById('d-prog');
476  if(!(CSS.supports&&CSS.supports('animation-timeline','scroll()'))){
477    var tick=function(){
478      var h=document.documentElement.scrollHeight-innerHeight;
479      prog.style.width=(h>0?(scrollY/h*100):0)+'%';
480    };
481    addEventListener('scroll',tick,{passive:true}); tick();
482  }
483
484  /* подсветка активного периода */
485  root.querySelectorAll('.chip').forEach(function(c){
486    c.addEventListener('click',function(){
487      root.querySelectorAll('.chip').forEach(function(x){x.dataset.on='0';});
488      c.dataset.on='1';
489    });
490  });
491
492  /* ---------- подгонка длинных сумм ----------
493     clamp() знает только ширину окна, а не длину строки: если сумма вырастет на разряд,
494     кегль ужимается ровно настолько, чтобы число влезло целиком. Ни переноса, ни обрезки. */
495  (function(){
496    var MIN=13;                       /* ниже не опускаемся: цифра должна оставаться цифрой */
497    function avail(box){
498      var cs=getComputedStyle(box);
499      return box.clientWidth-parseFloat(cs.paddingLeft)-parseFloat(cs.paddingRight);
500    }
501    function fit(n){
502      var box=n.parentElement;
503      if(!box||!box.clientWidth) return;
504      n.style.fontSize='';                       /* возвращаем кегль из CSS и меряем от него */
505      var max=parseFloat(getComputedStyle(n).fontSize), space=avail(box), w=n.scrollWidth;
506      if(w>space) n.style.fontSize=Math.max(MIN,Math.floor(max*space/w*10)/10)+'px';
507    }
508    function all(){
509      [].slice.call(root.querySelectorAll('.fg .n, .kid .need b')).forEach(fit);
510    }
511    root.fitNumbers=all;
512    all();
513    if(document.fonts&&document.fonts.ready) document.fonts.ready.then(all);
514    addEventListener('resize',function(){ clearTimeout(all.t); all.t=setTimeout(all,120); },{passive:true});
515  })();
516
517  /* ---------- плитки цифр: прожектор и кривая роста ---------- */
518  (function(){
519    var calm2=matchMedia('(prefers-reduced-motion: reduce)').matches;
520    [].slice.call(root.querySelectorAll('.fg')).forEach(function(fg,idx){
521      var c=document.createElement('canvas'); c.className='spark'; c.setAttribute('aria-hidden','true');
522      fg.appendChild(c);
523      var ctx=c.getContext('2d'), w=0,h=0, pts=[], grow=0, raf=0, tone;
524      var tones=[[108,123,255],[53,214,164],[255,194,92],[169,139,255]];
525      tone=tones[idx%tones.length];
526      function size(){
527        var d=Math.min(devicePixelRatio||1,2);
528        w=fg.clientWidth; h=fg.clientHeight;
529        c.width=Math.round(w*d); c.height=Math.round(h*d);
530        ctx.setTransform(d,0,0,d,0,0);
531        pts=[]; var seed=idx*97+13;
532        for(var i=0;i<=30;i++){
533          seed=(seed*1103515245+12345)%2147483648;
534          var noise=(seed/2147483648-.5)*.14;
535          var v=Math.pow(i/30,2.1)*.94+.04+noise*(i/30);
536          pts.push(Math.min(.99,Math.max(.02,v)));
537        }
538      }
539      function draw(){
540        if(!w) size();
541        ctx.clearRect(0,0,w,h);
542        var n=Math.max(2,Math.round(pts.length*grow));
543        var col='rgba('+tone[0]+','+tone[1]+','+tone[2]+',';
544        ctx.beginPath();
545        for(var i=0;i<n;i++){
546          var x=i/(pts.length-1)*w, y=h-3-pts[i]*(h-14);
547          i? ctx.lineTo(x,y):ctx.moveTo(x,y);
548        }
549        ctx.strokeStyle=col+'.95)'; ctx.lineWidth=1.8; ctx.lineJoin='round'; ctx.lineCap='round'; ctx.stroke();
550        ctx.lineTo((n-1)/(pts.length-1)*w,h); ctx.lineTo(0,h); ctx.closePath();
551        var g=ctx.createLinearGradient(0,h*.1,0,h);
552        g.addColorStop(0,col+'.22)'); g.addColorStop(1,col+'0)');
553        ctx.fillStyle=g; ctx.fill();
554        if(n>1){                                   /* яркая точка на вершине роста */
555          var lx=(n-1)/(pts.length-1)*w, ly=h-3-pts[n-1]*(h-14);
556          ctx.beginPath(); ctx.arc(lx,ly,2.6,0,6.283); ctx.fillStyle=col+'1)'; ctx.fill();
557        }
558      }
559      function run(target){
560        cancelAnimationFrame(raf);
561        (function step(){
562          grow+=(target-grow)*(calm2?1:.045);
563          draw();
564          if(Math.abs(target-grow)>.004) raf=requestAnimationFrame(step);
565        })();
566      }
567      fg.addEventListener('pointermove',function(e){
568        var r=fg.getBoundingClientRect();
569        fg.style.setProperty('--mx',((e.clientX-r.left)/r.width*100)+'%');
570        fg.style.setProperty('--my',((e.clientY-r.top)/r.height*100)+'%');
571      },{passive:true});
572      fg.addEventListener('pointerenter',function(){ grow=0; run(1); },{passive:true});
573      fg.addEventListener('pointerleave',function(){
574        fg.style.removeProperty('--mx'); fg.style.removeProperty('--my'); run(1);
575      },{passive:true});
576      addEventListener('resize',function(){ size(); draw(); },{passive:true});
577      size(); grow=1; draw();
578    });
579  })();
580
581  /* ---------- отчёты: живой график ----------
582     Данные настоящие: снимок /api/donation-month-reports/app — тот же источник, что у amCharts
583     на нынешней странице. Выделение периода мышью повторяет range selector amCharts. */
584  (function(){
585    var svg=document.getElementById('d-plot'), tip=document.getElementById('d-tip'),
586        wrapEl=document.getElementById('d-plotwrap'), keysEl=document.getElementById('d-keys'),
587        selBox=document.getElementById('d-sel');
588    if(!svg||!SNAP.chart) return;
589    var NS='http://www.w3.org/2000/svg';
590    var COLORS=['#6c7bff','#35d6a4','#ffc25c','#a98bff'];
591    var series=SNAP.chart.sources.map(function(name,i){
592      return {name:name,color:COLORS[i%COLORS.length],off:false,
593              data:SNAP.chart.rows.map(function(r){ return r[i+1]; })};
594    });
595    /* С графика уходит только то, по чему за последний год не пришло вообще ничего
596       (сейчас это мобильные платежи). Копейки по SMS показываем: точность важнее чистоты
597       картинки. Проверка живая — пойдут деньги, и кривая вернётся сама. */
598    (function(){
599      var tail=Math.min(12,SNAP.chart.rows.length), start=SNAP.chart.rows.length-tail;
600      series=series.filter(function(z){
601        for(var i=start;i<z.data.length;i++) if(z.data[i]>0) return true;
602        return false;
603      });
604    })();
605    var months=SNAP.chart.rows.map(function(r){
606      var p2=r[0].split('-'); return new Date(+p2[0],+p2[1]-1,1);
607    });
608    var MON=['января','февраля','марта','апреля','мая','июня','июля','августа','сентября','октября','ноября','декабря'];
609    function money(v){ return String(Math.round(v)).replace(/\B(?=(\d{3})+(?!\d))/g,' ')+' ₽'; }
610    function el(n,at){ var e=document.createElementNS(NS,n); for(var k in at) e.setAttribute(k,at[k]); return e; }
611
612    var W=900,H=320,L=62,R=12,T=14,B=30;
613    var from=0, to=months.length-1, hoverI=-1;
614    var xOf=function(i){ return L+(i-from)/Math.max(to-from,1)*(W-L-R); };
615
616    function draw(){
617      while(svg.firstChild) svg.removeChild(svg.firstChild);
618      var live=series.filter(function(z){return !z.off;});
619      var max=1, i;
620      for(i=from;i<=to;i++) live.forEach(function(z){ if(z.data[i]>max) max=z.data[i]; });
621      max*=1.12;
622      var y=function(v){ return T+(1-v/max)*(H-T-B); };
623
624      for(var g=0;g<=4;g++){
625        var vy=T+(H-T-B)*g/4, val=max*(1-g/4);
626        svg.appendChild(el('line',{x1:L,x2:W-R,y1:vy,y2:vy,'class':'plot-grid'}));
627        var lb=el('text',{x:L-10,y:vy+4,'class':'plot-lbl','text-anchor':'end'});
628        lb.textContent = val>=1e6 ? (val/1e6).toFixed(1)+' млн' : Math.round(val/1000)+' тыс';
629        svg.appendChild(lb);
630      }
631      var span=to-from, seen={};
632      for(i=from;i<=to;i++){
633        var d=months[i], yr=d.getFullYear(), put=false;
634        if(span<=14) put=true;
635        else if(span<=40) put=(d.getMonth()%3===0);
636        else put=(d.getMonth()===0 && !seen[yr]);
637        if(!put) continue;
638        seen[yr]=1;
639        var tx=el('text',{x:xOf(i),y:H-8,'class':'plot-lbl','text-anchor':'middle'});
640        tx.textContent = span<=40 ? MON[d.getMonth()].slice(0,3)+' '+String(yr).slice(2) : yr;
641        svg.appendChild(tx);
642      }
643      live.forEach(function(z){
644        var dd='', ar='M'+xOf(from)+','+y(0);
645        for(var k=from;k<=to;k++){
646          dd+=(k===from?'M':'L')+xOf(k)+','+y(z.data[k]);
647          ar+='L'+xOf(k)+','+y(z.data[k]);
648        }
649        ar+='L'+xOf(to)+','+y(0)+'Z';
650        svg.appendChild(el('path',{d:ar,fill:z.color,opacity:.09}));
651        svg.appendChild(el('path',{d:dd,'class':'plot-line',stroke:z.color}));
652      });
653      if(hoverI>=from&&hoverI<=to){
654        svg.appendChild(el('line',{x1:xOf(hoverI),x2:xOf(hoverI),y1:T,y2:H-B,'class':'plot-cursor'}));
655        live.forEach(function(z){
656          svg.appendChild(el('circle',{cx:xOf(hoverI),cy:y(z.data[hoverI]),r:4,
657            fill:z.color,stroke:'var(--surf)','stroke-width':2}));
658        });
659      }
660      var zo=document.getElementById('d-zoomout');
661      if(zo) zo.hidden=(from===0&&to===months.length-1);
662    }
663    function keys(){
664      keysEl.innerHTML='';
665      series.forEach(function(z){
666        var b2=document.createElement('button');
667        b2.className='key'; b2.type='button'; b2.style.color=z.color;
668        b2.dataset.off=z.off?'1':'0';
669        b2.innerHTML='<i></i><span style="color:var(--dim)">'+z.name+'</span>';
670        b2.addEventListener('click',function(){
671          z.off=!z.off;
672          if(series.every(function(q){return q.off;})) z.off=false;
673          keys(); draw();
674        });
675        keysEl.appendChild(b2);
676      });
677    }
678    /* индекс месяца под курсором */
679    function idxAt(clientX){
680      var r=svg.getBoundingClientRect();
681      var rel=(clientX-r.left)/r.width*W;
682      var i=Math.round(from+(rel-L)/(W-L-R)*(to-from));
683      return Math.min(to,Math.max(from,i));
684    }
685    function showTip(e,i){
686      var d=months[i], live=series.filter(function(z){return !z.off;}), total=0;
687      var html='<h6>'+MON[d.getMonth()]+' '+d.getFullYear()+'</h6>';
688      live.forEach(function(z){
689        total+=z.data[i];
690        html+='<div><span><i style="background:'+z.color+'"></i>'+z.name+'</span><b>'+money(z.data[i])+'</b></div>';
691      });
692      html+='<div><span>Всего</span><b>'+money(total)+'</b></div>';
693      tip.innerHTML=html; tip.hidden=false;
694      var box=wrapEl.getBoundingClientRect(), tw=tip.offsetWidth;
695      var px=e.clientX-box.left+14;
696      if(px+tw>box.width) px=e.clientX-box.left-tw-14;
697      tip.style.left=Math.max(4,px)+'px';
698      tip.style.top=Math.min(box.height-tip.offsetHeight-4,Math.max(4,e.clientY-box.top-20))+'px';
699    }
700
701    /* --- выделение периода мышью --- */
702    var dragFrom=null, dragX=0;
703    wrapEl.addEventListener('pointerdown',function(e){
704      if(e.button) return;
705      dragFrom=idxAt(e.clientX); dragX=e.clientX-wrapEl.getBoundingClientRect().left;
706      selBox.style.display='block';
707      selBox.style.left=dragX+'px'; selBox.style.width='0px';
708      wrapEl.setPointerCapture&&wrapEl.setPointerCapture(e.pointerId);
709    });
710    wrapEl.addEventListener('pointermove',function(e){
711      var i=idxAt(e.clientX);
712      if(dragFrom!==null){
713        var x=e.clientX-wrapEl.getBoundingClientRect().left;
714        selBox.style.left=Math.min(dragX,x)+'px';
715        selBox.style.width=Math.abs(x-dragX)+'px';
716        tip.hidden=true;
717        return;
718      }
719      if(i===hoverI) return;
720      hoverI=i; draw(); showTip(e,i);
721    },{passive:true});
722    function endDrag(e){
723      if(dragFrom===null) return;
724      var i=idxAt(e.clientX), a2=Math.min(dragFrom,i), b2=Math.max(dragFrom,i);
725      dragFrom=null; selBox.style.display='none';
726      if(b2-a2>=2){                       /* случайный клик не должен схлопывать график */
727        from=a2; to=b2; hoverI=-1;
728        root.querySelectorAll('.chip').forEach(function(c){ c.dataset.on='0'; });
729        draw();
730      }
731    }
732    wrapEl.addEventListener('pointerup',endDrag);
733    wrapEl.addEventListener('pointercancel',endDrag);
734    wrapEl.addEventListener('pointerleave',function(){ hoverI=-1; tip.hidden=true; draw(); },{passive:true});
735
736    root.querySelectorAll('.chip').forEach(function(c){
737      c.addEventListener('click',function(){
738        var m=+c.dataset.months||0;
739        to=months.length-1;
740        from=m? Math.max(0,months.length-m):0;
741        hoverI=-1; tip.hidden=true; draw();
742      });
743    });
744    var zo=document.getElementById('d-zoomout');
745    if(zo) zo.addEventListener('click',function(){
746      from=0; to=months.length-1; hoverI=-1; tip.hidden=true;
747      root.querySelectorAll('.chip').forEach(function(c){ c.dataset.on=(+c.dataset.months===0)?'1':'0'; });
748      draw();
749    });
750    keys(); draw();
751  })();
752
753  /* ---------- программы и благодарности ----------
754     Обложки нарисованы в стиле утверждённых 3D-сканов; в бою сюда встают настоящие изображения. */
755  (function(){
756    function cvIn(host,w,h){
757      var c=document.createElement('canvas'); c.width=w; c.height=h; host.appendChild(c); return c;
758    }
759    function scan(c,kind,tone){
760      var x=c.getContext('2d'), w=c.width, h=c.height;
761      var g=x.createLinearGradient(0,0,w,h);
762      g.addColorStop(0,'#0d1220'); g.addColorStop(1,'#141b2c');
763      x.fillStyle=g; x.fillRect(0,0,w,h);
764      var glow=x.createRadialGradient(w*.52,h*.5,10,w*.52,h*.5,w*.55);
765      glow.addColorStop(0,'rgba('+tone+',.42)'); glow.addColorStop(1,'rgba('+tone+',0)');
766      x.fillStyle=glow; x.fillRect(0,0,w,h);
767      x.save(); x.translate(w*.5,h*.54); x.strokeStyle='rgba('+tone+',.85)'; x.lineWidth=1.4;
768      x.shadowColor='rgba('+tone+',.9)'; x.shadowBlur=14;
769      var R=Math.min(w,h)*.32, i, a;
770      if(kind==='face'){
771        for(i=0;i<26;i++){
772          x.globalAlpha=.16+.5*(1-i/26);
773          x.beginPath();
774          x.moveTo(-R*.75, -R*.9+i*R*.07);
775          x.bezierCurveTo(R*.2,-R*1.05+i*R*.06, R*.95,-R*.1+i*R*.05, R*.28, R*.95-i*R*.02);
776          x.stroke();
777        }
778      } else if(kind==='neuro'){
779        for(i=0;i<14;i++){
780          x.globalAlpha=.2+.45*(1-i/14);
781          x.beginPath();
782          for(a=-Math.PI;a<=Math.PI;a+=.08){
783            var rr=R*(1+.12*Math.sin(a*6+i*.7)+.05*Math.sin(a*11-i));
784            var px=Math.cos(a)*rr*(1+i*.012), py=Math.sin(a)*rr*.8*(1+i*.012);
785            a===-Math.PI? x.moveTo(px,py):x.lineTo(px,py);
786          }
787          x.stroke();
788        }
789      } else {
790        for(i=0;i<20;i++){
791          x.globalAlpha=.18+.45*(1-i/20);
792          x.beginPath();
793          for(a=0;a<7.6;a+=.09){
794            var r2=R*(.16+a*.12)*(1+i*.01);
795            var qx=Math.cos(a+i*.05)*r2, qy=Math.sin(a+i*.05)*r2*.92;
796            a===0? x.moveTo(qx,qy):x.lineTo(qx,qy);
797          }
798          x.stroke();
799        }
800      }
801      x.restore();
802      x.globalAlpha=.1; x.strokeStyle='#9fb4ff'; x.lineWidth=1; x.shadowBlur=0;
803      for(i=0;i<h;i+=7){ x.beginPath(); x.moveTo(0,i); x.lineTo(w,i); x.stroke(); }
804      x.globalAlpha=1;
805    }
806    /* обложки видеоблагодарностей в наброске телефона */
807    var tones=[[108,123,255],[53,214,164],[255,194,92],[169,139,255]];
808    [].slice.call(root.querySelectorAll('.shelf .thx')).forEach(function(card,i){
809      var q=tones[i%tones.length];
810      var c=document.createElement('canvas'); c.width=184; c.height=286;
811      c.style.cssText='position:absolute;inset:0;width:100%;height:100%;display:block';
812      var x=c.getContext('2d');
813      var g=x.createLinearGradient(0,0,184,286);
814      g.addColorStop(0,'rgb('+Math.round(q[0]*.5)+','+Math.round(q[1]*.5)+','+Math.round(q[2]*.55)+')');
815      g.addColorStop(1,'#141a26');
816      x.fillStyle=g; x.fillRect(0,0,184,286);
817      for(var k=0;k<6;k++){
818        var rg=x.createRadialGradient(20+Math.random()*140,30+Math.random()*200,3,
819                                      20+Math.random()*140,30+Math.random()*200,64);
820        rg.addColorStop(0,'rgba(255,255,255,.10)'); rg.addColorStop(1,'rgba(255,255,255,0)');
821        x.fillStyle=rg; x.fillRect(0,0,184,286);
822      }
823      card.insertBefore(c,card.firstChild);
824    });
825  })();
826
827  /* ---------- звёздный ландшафт на кнопках ----------
828     Зацикленная сцена рисуется процедурно: бесконечная прокрутка без видеофайла. */
829  (function(){
830    var quiet=matchMedia('(prefers-reduced-motion: reduce)').matches;
831    var tones=[[108,123,255],[53,214,164],[255,194,92],[169,139,255]];
832    [].slice.call(root.querySelectorAll('.steps .sp')).forEach(function(btn,idx){
833      var tone=tones[idx%tones.length];
834      var c=document.createElement('canvas'); c.className='sky'; c.setAttribute('aria-hidden','true');
835      btn.insertBefore(c,btn.firstChild);
836      c.setAttribute('aria-hidden','true');
837      var ctx=c.getContext('2d'), w=0,h=0,stars=[],t=0,raf=0,last=0;
838
839      function size(){
840        var d=Math.min(devicePixelRatio||1,2);
841        w=btn.clientWidth; h=btn.clientHeight;
842        if(!w||!h) return;
843        c.width=Math.round(w*d); c.height=Math.round(h*d);
844        ctx.setTransform(d,0,0,d,0,0);
845        stars=[];
846        for(var i=0;i<48;i++) stars.push({
847          x:Math.random()*w, y:Math.random()*h*.74,
848          z:.35+Math.random()*1, r:Math.random()<.22?1.3:.75
849        });
850      }
851      function hill(off,amp,base,speed,fill){
852        ctx.beginPath(); ctx.moveTo(0,h);
853        for(var x=0;x<=w;x+=4){
854          var y=base+Math.sin((x+t*speed)*.017+off)*amp+Math.sin((x+t*speed)*.006+off*2)*amp*.6;
855          ctx.lineTo(x,y);
856        }
857        ctx.lineTo(w,h); ctx.closePath(); ctx.fillStyle=fill; ctx.fill();
858      }
859      function mix(c,к){ return Math.round(c+(255-c)*к); }
860      function frame(now){
861        if(!w||!h){ size(); if(!w||!h){ raf=requestAnimationFrame(frame); return; } }
862        var dt=Math.min((now-last)/16.7,3)||1; last=now;
863        if(!quiet) t+=dt;
864        var day=currentTheme()==='light';
865        var g=ctx.createLinearGradient(0,0,0,h);
866        if(day){
867          /* днём небо светлое: та же сцена, но без резкого чёрного пятна на белой странице */
868          g.addColorStop(0,'rgb('+mix(tone[0],.86)+','+mix(tone[1],.88)+','+mix(tone[2],.9)+')');
869          g.addColorStop(1,'rgb('+mix(tone[0],.55)+','+mix(tone[1],.6)+','+mix(tone[2],.68)+')');
870        } else {
871          g.addColorStop(0,'rgb('+Math.round(tone[0]*.16)+','+Math.round(tone[1]*.17)+','+Math.round(tone[2]*.28)+')');
872          g.addColorStop(1,'rgb('+Math.round(tone[0]*.34)+','+Math.round(tone[1]*.36)+','+Math.round(tone[2]*.5)+')');
873        }
874        ctx.fillStyle=g; ctx.fillRect(0,0,w,h);
875        for(var i=0;i<stars.length;i++){
876          var st=stars[i];
877          if(!quiet){ st.x-=st.z*.32*dt; if(st.x<-2) st.x=w+2; }
878          ctx.globalAlpha=day? (.10+st.z*.16) : (.35+st.z*.55);
879          ctx.fillStyle=day? 'rgb('+mix(tone[0],.35)+','+mix(tone[1],.4)+','+mix(tone[2],.5)+')' : '#fff';
880          ctx.fillRect(st.x,st.y,st.r,st.r);
881        }
882        ctx.globalAlpha=1;
883        if(day){
884          hill(0,  h*.10, h*.72, .55, 'rgba('+tone[0]+','+tone[1]+','+tone[2]+',.20)');
885          hill(2.1,h*.13, h*.86, 1.1, 'rgba('+Math.round(tone[0]*.7)+','+Math.round(tone[1]*.72)+','+Math.round(tone[2]*.8)+',.45)');
886        } else {
887          hill(0,  h*.10, h*.72, .55, 'rgba('+tone[0]+','+tone[1]+','+tone[2]+',.30)');
888          hill(2.1,h*.13, h*.86, 1.1, 'rgba(14,17,26,.85)');
889        }
890        raf=requestAnimationFrame(frame);
891      }
892      function start(){ if(raf) return; size(); last=performance.now(); raf=requestAnimationFrame(frame); }
893      function stop(){ if(!raf) return; cancelAnimationFrame(raf); raf=0; }
894      btn.addEventListener('pointerenter',start);
895      btn.addEventListener('focus',start);
896      btn.addEventListener('pointerleave',function(){ setTimeout(stop,420); });
897      btn.addEventListener('blur',function(){ setTimeout(stop,420); });
898      addEventListener('resize',function(){ if(raf) size(); },{passive:true});
899    });
900  })();
901
902  /* ---------- 3D-слой в герое ---------- */
903  var canvas=document.getElementById('d-scene');
904  var calm=matchMedia('(prefers-reduced-motion: reduce)').matches;
905  if(window.THREE&&canvas){
906    var host=canvas.parentNode,
907        renderer=new THREE.WebGLRenderer({canvas:canvas,alpha:true,antialias:false,powerPreference:'low-power'}),
908        scene=new THREE.Scene(),
909        camera=new THREE.PerspectiveCamera(55,1,.1,100);
910    camera.position.z=4.2;
911    /* палитра карточек по теме: на светлом фоне летают светлые карточки */
912    var SK={dark:{card:'#1b2130',edge:'rgba(255,255,255,.12)',name:'#ffffff',dim:'#8b909c',
913                  soft:'#c4c8d0',screen:'#151a25',row:'#181a21',bar:'#1b1d24',
914                  line1:'#2f323c',line2:'#24272f',fog:0x181d28,dust:0.72,ring:0x7aa6ff},
915            light:{card:'#ffffff',edge:'rgba(20,25,38,.14)',name:'#141926',dim:'#67708a',
916                  soft:'#3d4457',screen:'#f7f9fd',row:'#e9edf6',bar:'#eef1f8',
917                  line1:'#ccd3e2',line2:'#dde3ee',fog:0xeef1f7,dust:0.5,ring:0x4a59e8}};
918    var SKIN=SK[currentTheme()]||SK.dark;
919    /* Насколько подсвечивать пойманную карточку. На тёмном фоне дымка съедает контраст
920       и подсветка нужна; на светлом та же подсветка выбеливает фото и текст. */
921    var HL=currentTheme()==='light'?0:.55;
922    scene.fog=new THREE.FogExp2(SKIN.fog,.105);
923
924    /* Текстуры рисуем на canvas — в бою на их место встают настоящие фото детей
925       из базы и реальные скриншоты приложения. */
926    var CO='#fb5039', pal=['#6c7bff','#35d6a4','#ffc25c','#a98bff'];
927
928    function cv(w,h){var c=document.createElement('canvas');c.width=w;c.height=h;return c;}
929    function rr(x,g,y,w,h,r){x.beginPath();x.moveTo(g+r,y);x.arcTo(g+w,y,g+w,y+h,r);
930      x.arcTo(g+w,y+h,g,y+h,r);x.arcTo(g,y+h,g,y,r);x.arcTo(g,y,g+w,y,r);x.closePath();}
931    function wrap(x,text,left,top,maxw,lh,lines){
932      var words=text.split(' '),line='',n=0;
933      for(var i=0;i<words.length&&n<lines;i++){
934        var t=line?line+' '+words[i]:words[i];
935        if(x.measureText(t).width>maxw&&line){x.fillText(line,left,top+n*lh);n++;line=words[i];}
936        else line=t;
937      }
938      if(n<lines&&line)x.fillText(line,left,top+n*lh);
939    }
940    var MAXA=renderer.capabilities.getMaxAnisotropy?renderer.capabilities.getMaxAnisotropy():1;
941    function tex(c){
942      var t=new THREE.CanvasTexture(c);
943      t.anisotropy=MAXA; t.minFilter=THREE.LinearMipmapLinearFilter; t.generateMipmaps=true;
944      return t;
945    }
946    /* холсты — строго степень двойки, иначе WebGL отключает мипмапы и мелкое мылится */
947    function letterTex(name,diag,sum,tint,photo){
948      var c=cv(512,256),x=c.getContext('2d');
949      x.fillStyle=SKIN.card;rr(x,0,0,512,256,30);x.fill();
950      x.strokeStyle=SKIN.edge;x.lineWidth=3;x.stroke();
951      var g=x.createLinearGradient(24,24,170,200);g.addColorStop(0,tint);g.addColorStop(1,'#20232c');
952      x.fillStyle=g;rr(x,24,28,132,132,24);x.fill();
953      if(photo&&photo.complete&&photo.naturalWidth){
954        x.save(); rr(x,24,28,132,132,24); x.clip();
955        x.drawImage(photo,24,28,132,132); x.restore();
956      } else {
957        x.fillStyle='rgba(255,255,255,.6)';x.font='600 54px system-ui,sans-serif';x.textAlign='center';
958        x.fillText(name.charAt(0),90,112);x.textAlign='left';
959      }
960      x.fillStyle=SKIN.name;x.font='700 32px system-ui,sans-serif';x.fillText(name,176,58);
961      x.fillStyle=SKIN.dim;x.font='400 22px system-ui,sans-serif';wrap(x,diag,176,92,312,27,3);
962      x.fillStyle=SKIN.soft;x.font='400 22px system-ui,sans-serif';x.fillText('Не хватает',24,206);
963      x.fillStyle=CO;x.font='700 34px system-ui,sans-serif';x.fillText(sum,24,240);
964      return tex(c);
965    }
966    function thanksTex(tint,name,photo){
967      var c=cv(128,256),x=c.getContext('2d');
968      if(photo&&photo.complete&&photo.naturalWidth){
969        /* кадр из видео вписываем по короткой стороне, лишнее срезаем по центру */
970        var s2=Math.max(128/photo.naturalWidth,256/photo.naturalHeight);
971        var dw=photo.naturalWidth*s2, dh=photo.naturalHeight*s2;
972        x.save(); rr(x,0,0,128,256,18); x.clip();
973        x.drawImage(photo,(128-dw)/2,(256-dh)/2,dw,dh);
974        x.restore();
975      } else {
976        var g=x.createLinearGradient(0,0,128,256);g.addColorStop(0,tint);g.addColorStop(1,SKIN.card);
977        x.fillStyle=g;rr(x,0,0,128,256,18);x.fill();
978      }
979      x.strokeStyle='rgba(255,255,255,.9)';x.lineWidth=3;
980      x.beginPath();x.arc(64,104,22,0,6.3);x.stroke();
981      x.fillStyle='#fff';x.beginPath();x.moveTo(58,92);x.lineTo(78,104);x.lineTo(58,116);x.closePath();x.fill();
982      var grad=x.createLinearGradient(0,150,0,256);
983      grad.addColorStop(0,'rgba(10,13,20,0)'); grad.addColorStop(1,'rgba(10,13,20,.88)');
984      x.fillStyle=grad; x.fillRect(0,150,128,106);
985      x.textAlign='center'; x.fillStyle='#ffffff'; x.font='700 15px system-ui,sans-serif';
986      var parts=name.split(' ');
987      x.fillText(parts[0],64,214);
988      if(parts[1]) x.fillText(parts[1],64,232);
989      x.fillStyle='rgba(255,255,255,.72)'; x.font='400 11px system-ui,sans-serif';
990      x.fillText('лечение позади',64,194);
991      x.textAlign='left';
992      return tex(c);
993    }
994
995    /* Картинка с другого домена, нарисованная на холсте, делает его tainted, и WebGL
996       отказывается брать из него текстуру. В прототипе фото были вшиты в страницу и
997       вопроса не возникало, в бою они приходят с im.rusfond.ru — поэтому просим CORS
998       явно (оба источника отдают Access-Control-Allow-Origin: *). */
999    function photo(url){
1000      if(!url) return null;
1001      var im=new Image();
1002      im.crossOrigin='anonymous';
1003      im.src=url;
1004      return im;
1005    }
1006    /* Писем в снимке столько, сколько их открыто на сайте, а в кадре сцены помещается
1007       полтора десятка: берём ровно столько, сколько ляжет на карточки, иначе грузили бы
1008       фотографии, которые никто не увидит. Дальше по коду src[4] — готовая картинка:
1009       перерисовка по загрузке и переключение темы ждут именно её. */
1010    var inScene=innerWidth<700?12:22;
1011    var src=kids.slice(0,inScene).map(function(k){
1012      return [k.n,k.d,k.s,k.u,photo(k.p)];
1013    });
1014    /* благодарности для сцены — тот же снимок API, что и в телефоне, с кадрами из видео */
1015    var thanksSrc=(SNAP.thanks||[]).map(function(t){
1016      return [t.n, t.u, photo(t.cover)];
1017    });
1018    if(!thanksSrc.length) thanksSrc=[['Благодарность','https://rusfond.ru/thanks',null]];
1019
1020    var flock=new THREE.Group(); flock.position.z=-4.8; scene.add(flock);
1021    var items=[], total=inScene;
1022    for(var i=0;i<total;i++){
1023      var tint=pal[i%pal.length], kind=(i%4===3)?4:0, tx2, w, h;
1024      var url,label;
1025      if(kind===4){ var th=thanksSrc[i%thanksSrc.length]; tx2=thanksTex(tint,th[0],th[2]); w=.38; h=.76; url=th[1]; label='Благодарность: '+th[0]; }
1026      else { var d=src[i%src.length]; tx2=letterTex(d[0],d[1],d[2],tint,d[4]); w=1.08; h=.54; url=d[3]; label='Письмо: '+d[0]; }
1027      var m=new THREE.Mesh(new THREE.PlaneGeometry(w,h),
1028        new THREE.MeshBasicMaterial({map:tx2,transparent:true,opacity:.82,depthWrite:false,fog:true}));
1029      var it={m:m,ang:(i/ (total||1))*6.283+Math.random()*.35,rad:2.25+Math.random()*1.75,
1030              y:(Math.random()-.5)*3.1,sp:.055+Math.random()*.075,bob:Math.random()*6.283,
1031              url:url,label:label,hover:0,k:0,kind:kind,tint:tint,
1032              src:(kind===4)?thanksSrc[i%thanksSrc.length]:src[i%src.length]};
1033      m.renderOrder=1; m.userData.it=it;
1034      items.push(it); flock.add(m);
1035    }
1036
1037    /* цветные квадратики — та самая живая динамика на фоне, только мельче */
1038    var dn=innerWidth<700?900:2000, dp=new Float32Array(dn*3), dc=new Float32Array(dn*3);
1039    var rgbs=[[.42,.48,1],[.21,.84,.64],[1,.76,.36],[.66,.55,1]];
1040    for(var j=0;j<dn;j++){
1041      var th=Math.random()*6.283, r2=1.5+Math.random()*2.9;
1042      dp[j*3]=Math.cos(th)*r2; dp[j*3+1]=(Math.random()-.5)*3.8; dp[j*3+2]=Math.sin(th)*r2-5.6;
1043      var q=rgbs[(Math.random()*rgbs.length)|0];
1044      dc[j*3]=q[0];dc[j*3+1]=q[1];dc[j*3+2]=q[2];
1045    }
1046    var dg=new THREE.BufferGeometry();
1047    dg.setAttribute('position',new THREE.BufferAttribute(dp,3));
1048    dg.setAttribute('color',new THREE.BufferAttribute(dc,3));
1049    var dust=new THREE.Points(dg,new THREE.PointsMaterial({size:.017,vertexColors:true,
1050      transparent:true,opacity:SKIN.dust,depthWrite:false,depthTest:false,
1051      blending:(currentTheme()==='light'?THREE.NormalBlending:THREE.AdditiveBlending)}));
1052    dust.renderOrder=-2; scene.add(dust);
1053
1054    var ring=new THREE.Mesh(
1055      new THREE.TorusGeometry(5.5,.009,3,220),
1056      new THREE.MeshBasicMaterial({color:SKIN.ring,transparent:true,opacity:.5,depthTest:false})
1057    );
1058    ring.renderOrder=-1;
1059    ring.rotation.x=1.15; ring.position.z=-4.8; scene.add(ring);
1060
1061    function size(){
1062      var w=host.clientWidth,h=host.clientHeight;
1063      renderer.setPixelRatio(Math.min(devicePixelRatio||1,1.6));
1064      renderer.setSize(w,h,false);
1065      camera.aspect=w/h; camera.updateProjectionMatrix();
1066    }
1067    size(); addEventListener('resize',size,{passive:true});
1068    /* высота hero меняется и без resize окна — раскрыли «Как установить APK», подставилась
1069       версия из манифеста; без пересчёта холст растягивается и карточки сплющиваются */
1070    if(window.ResizeObserver) new ResizeObserver(size).observe(host);
1071
1072    var mx=0,my=0,tx=0,ty=0,live=true;
1073    var ray=new THREE.Raycaster(), ptr=new THREE.Vector2(), caught=null;
1074    var CATCH_DIST=13.5;  /* ловятся и дальние: от дрожания спасает удержание захвата ниже */
1075
1076    function pick(e){
1077      var r=canvas.getBoundingClientRect();
1078      var nx=(e.clientX-r.left)/r.width, ny=(e.clientY-r.top)/r.height;
1079      /* слева сцена скрыта маской — там ловить нечего */
1080      if(innerWidth>960 && nx<.38) return null;
1081      ptr.set(nx*2-1, -(ny*2-1));
1082      ray.setFromCamera(ptr,camera);
1083      var hits=ray.intersectObjects(flock.children,false);
1084      for(var i=0;i<hits.length;i++){
1085        var cand=hits[i].object.userData.it;
1086        /* вылетевшую карточку не ловим повторно: она закрывает собой всё остальное */
1087        if(cand===caught) continue;
1088        /* ловим только тех, кто уже вышел на ближний план: дальние мелкие,
1089           и рывок от них к центру был бы слишком большим */
1090        if(hits[i].distance>CATCH_DIST) continue;   /* совсем за туманом не ловим */
1091        return cand;
1092      }
1093      return null;
1094    }
1095    function hold(it){
1096      if(caught===it) return;
1097      /* Дымка сцены и маска героя вместе съедают контраст: пойманное письмо выглядит
1098         затянутым пеленой, хотя оно уже полностью непрозрачно. Ð
1098’ыводим его из тумана —
1099         остальные карточки остаются в глубине, и поймать взглядом становится легче.
1100         Пересборка материала здесь не в тягость: ловля происходит раз в несколько секунд. */
1101      if(caught){ caught.m.material.fog=true;  caught.m.material.needsUpdate=true; }
1102      if(it)    { it.m.material.fog=false;     it.m.material.needsUpdate=true; }
1103      caught=it;
1104      canvas.title=it?it.label:'';
1105    }
1106    host.addEventListener('pointermove',function(e){
1107      var r=host.getBoundingClientRect();
1108      tx=((e.clientX-r.left)/r.width-.5)*.6;
1109      ty=((e.clientY-r.top)/r.height-.5)*.4;
1110    },{passive:true});
1111    /* .wrap лежит поверх канваса и перехватывал бы указатель, поэтому слушаем сам герой
1112       и пропускаем всё, что попало в интерактивный или текстовый элемент страницы */
1113    function overUI(e){
1114      var t=e.target;
1115      return !!(t&&t.closest&&t.closest('a,button,input,summary,.device,.figs,.badge,h1,.lede,.scenehint,.helpline'));
1116    }
1117    /* Карточка улетает из-под курсора, и луч перестаёт в неё попадать — если на это
1118       реагировать, получается дрожание: поймал, отпустил, снова поймал. Поэтому
1119       захват держим, пока указатель не отойдёт от места захвата или не попадёт
1120       на другую карточку. */
1121    var grabX=0, grabY=0, HOLD=110;
1122    host.addEventListener('pointermove',function(e){
1123      if(overUI(e)){ hold(null); host.style.cursor=''; return; }
1124      if(caught){
1125        var far=Math.abs(e.clientX-grabX)+Math.abs(e.clientY-grabY)>HOLD;
1126        var other=far?pick(e):null;
1127        if(other){ hold(other); grabX=e.clientX; grabY=e.clientY; }
1128        else if(far) hold(null);
1129        host.style.cursor=caught?'pointer':'';
1130        return;
1131      }
1132      var it=pick(e);
1133      if(it){ grabX=e.clientX; grabY=e.clientY; }
1134      hold(it);
1135      host.style.cursor=caught?'pointer':'';
1136    },{passive:true});
1137    host.addEventListener('pointerleave',function(){ hold(null); host.style.cursor=''; },{passive:true});
1138    /* window.open в песочнице iframe часто блокируется — тогда открываем обычной ссылкой */
1139    function openUrl(u){
1140      var win=null;
1141      try{ win=window.open(u,'_blank','noopener'); }catch(err){}
1142      if(win) return;
1143      var a=document.createElement('a');
1144      a.href=u; a.target='_blank'; a.rel='noopener';
1145      document.body.appendChild(a); a.click(); a.remove();
1146    }
1147    host.addEventListener('click',function(e){
1148      if(overUI(e)) return;
1149      var it=pick(e)||caught;          /* на тач-экране наведения нет, берём пойманную карточку */
1150      if(it&&it.url) openUrl(it.url);
1151    });
1152    if('IntersectionObserver' in window){
1153      new IntersectionObserver(function(es){live=es[0].isIntersecting;},{threshold:0}).observe(host);
1154    }
1155    function rebuild(){
1156      items.forEach(function(it){
1157        var old=it.m.material.map, t2;
1158        if(it.kind===4) t2=thanksTex(it.tint,it.src[0],it.src[2]);
1159        else t2=letterTex(it.src[0],it.src[1],it.src[2],it.tint,it.src[4]);
1160        it.m.material.map=t2; it.m.material.needsUpdate=true;
1161        if(old&&old.dispose) old.dispose();
1162      });
1163    }
1164    addEventListener('load',rebuild);
1165    setTimeout(rebuild,600);
1166
1167    /* Раньше фото были вшиты в страницу и к первой отрисовке были готовы. Теперь они
1168       грузятся по сети: ждём каждую и перерисовываем пачкой, чтобы не гонять 20 пересборок. */
1169    (function(){
1170      var timer=null;
1171      function later(){ clearTimeout(timer); timer=setTimeout(rebuild,120); }
1172      src.map(function(s){ return s[4]; })
1173         .concat(thanksSrc.map(function(t){ return t[2]; }))
1174         .forEach(function(im){
1175           if(im && !im.complete) im.addEventListener('load',later);
1176         });
1177    })();
1178
1179    themeHooks.push(function(mode){
1180      SKIN=SK[mode]||SK.dark;
1181      HL=(mode==='light')?0:.55;
1182      scene.fog=new THREE.FogExp2(SKIN.fog,.105);
1183      ring.material.color.setHex(SKIN.ring);
1184      dust.material.opacity=SKIN.dust;
1185      dust.material.blending=(mode==='light'?THREE.NormalBlending:THREE.AdditiveBlending);
1186      dust.material.needsUpdate=true;
1187      items.forEach(function(it){
1188        var old=it.m.material.map, t2;
1189        if(it.kind===4) t2=thanksTex(it.tint,it.src[0],it.src[2]);
1190        else t2=letterTex(it.src[0],it.src[1],it.src[2],it.tint,it.src[4]);
1191        it.m.material.map=t2; it.m.material.needsUpdate=true;
1192        if(old&&old.dispose) old.dispose();
1193      });
1194    });
1195
1196    var prev=0, stagePt=new THREE.Vector3();
1197    (function loop(t){
1198      requestAnimationFrame(loop);
1199      if(!live){prev=t;return;}
1200      var dt=Math.min((t-prev)/1000,.05); prev=t;
1201      mx+=(tx-mx)*.05; my+=(ty-my)*.05;
1202      /* Пойманная карточка вылетает в свободное окно между текстом и телефоном.
1203         Точку задаём в координатах экрана и переводим в сцену, поэтому она остаётся
1204         на месте при любом размере окна. */
1205      var wide=innerWidth>960;
1206      /* Карточка должна целиком уместиться в окно между текстом и телефоном, иначе
1207         уезжает под них. Считаем, сколько мировых единиц занимает это окно на нужной
1208         глубине, и подбираем глубину так, чтобы письмо в него вписалось. */
1209      var winShare=wide?.19:.62;                       /* доля ширины экрана под карточку */
1210      var halfFov=camera.fov*Math.PI/360;
1211      var need=1.08*1.25;                              /* ширина письма с учётом укрупнения */
1212      var depth=need/(2*Math.tan(halfFov)*camera.aspect*winShare);
1213      if(depth<3.4) depth=3.4;
1214      stagePt.set(wide?.12:0, wide?.06:.02, .5).unproject(camera);
1215      stagePt.sub(camera.position).normalize().multiplyScalar(depth).add(camera.position);
1216      flock.worldToLocal(stagePt);
1217      for(var k=0;k<items.length;k++){
1218        var it=items[k], on=(it===caught)?1:0;
1219        it.hover+=(on-it.hover)*.085;
1220        if(!calm) it.ang+=it.sp*dt*(1-it.hover);           /* пойманная карточка замирает */
1221        var yy=it.y+(calm?0:Math.sin(t*.0009+it.bob)*.12*(1-it.hover));
1222        var px=Math.cos(it.ang)*it.rad+1.05, py=yy, pz=Math.sin(it.ang)*it.rad;
1223        if(it.hover>.002){
1224          px+=(stagePt.x-px)*it.hover;
1225          py+=(stagePt.y-py)*it.hover;
1226          pz+=(stagePt.z-pz)*it.hover;
1227        }
1228        it.m.position.set(px,py,pz);
1229        it.m.quaternion.copy(camera.quaternion);
1230        var sc=1+it.hover*.25;             /* остальное укрупнение даёт сама перспектива */
1231        it.m.scale.set(sc,sc,1);
1232        var far=caught? .45 : .82;         /* соседние притухают, пойманная плотная */
1233        it.m.material.opacity=far+it.hover*(1-far);
1234        /* MeshBasicMaterial умножает текстуру на color: больше единицы — подсветка.
1235           Нужна как раз слева, где маска героя гасит сцену сильнее всего. */
1236        it.m.material.color.setScalar(1+it.hover*HL);
1237        it.m.renderOrder=1+Math.round(it.hover*3);
1238      }
1239      if(!calm){ dust.rotation.y=t*.00013; ring.rotation.z=t*.00014; }
1240      flock.rotation.x=my*.16; flock.position.x=mx*.5;
1241      dust.position.x=mx*.7;
1242      ring.position.x=mx*.4;
1243      renderer.render(scene,camera);
1244    })(0);
1245  }
1246})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.