PageSourceSearch

https://tagankateatr.ru/repertoire.js?6756

js tagankateatr.ru collected 2026-10-02 09:58:44 UTC 15,296 bytes, 291 lines download raw bytes

1/* ============================================================
2   REPERTOIRE.JS — Блок «В репертуаре», главная страница
3   Театр на Таганке
4
5   Архитектура: данные живут в HTML (data-* атрибуты на карточках).
6   JS читает DOM, фильтрует по табам, управляет видимостью,
7   обновляет ссылки «Весь репертуар», запускает fitCardTitles.
8   Для обновления контента — редактировать только index.html.
9
10   ── Пагинация ────────────────────────────────────────────────
11   На каждый таб показывается ограниченное число карточек:
12
13     • Стартовый показ — не более STEP (6). Если в табе ≤ 6 карточек
14       и их нечётное число — округляем вниз до чётного (5→4, 3→2),
15       чтобы сетка 2×N не оставляла одинокую карточку с пустой ячейкой.
16       Единственная карточка (total = 1) показывается как есть.
17
18     • Кнопка «Показать ещё» добавляет ещё STEP карточек.
19       Промежуточные состояния всегда чётные (6 → 12 → 18 …).
20       Последний клик, раскрывающий остаток, показывает ВСЁ —
21       даже если итог нечётный (одинокая карточка появляется только
22       после явного раскрытия пользователем, это допустимо).
23
24     • Кнопка видна, пока есть скрытые карточки таба. На мобилке
25       и десктопе. При переключении таба счётчик сбрасывается.
26   ============================================================ */
27
28(function () {
29  'use strict';
30
31  /* Шаг пагинации — стартовый максимум и размер догрузки */
32  const STEP = 6;
33
34  /* ══════════════════════════════════════════════════════════════
35     КОНФИГУРАЦИЯ ТАБОВ
36   ══════════════════════════════════════════════════════════════ */
37  const TABS = {
38    current:  { allText: 'Весь репертуар'        },
39    premiere: { allText: 'Все премьеры'           },
40    pushka:   { allText: 'Все спектакли по карте' },
41  };
42
43  /* ══════════════════════════════════════════════════════════════
44     ОСНОВНОЙ КОМПОНЕНТ
45   ══════════════════════════════════════════════════════════════ */
46  class RepertoireBlock {
47    constructor(rootEl) {
48      this._root      = rootEl;
49      this._cardsWrap = rootEl.querySelector('.repertoire__cards');
50      this._allBtn    = rootEl.querySelector('.repertoire__all-btn');
51      this._allHeader = rootEl.querySelector('.repertoire__all-header');
52      this._moreWrap  = rootEl.querySelector('.repertoire__more');
53      this._moreBtn   = rootEl.querySelector('.repertoire__more-btn');
54      this._activeTab = 'current';
55
56      this._allCards = [...rootEl.querySelectorAll('.rep-card[data-categories]')];
57
58      /* Сколько карточек показано в активном табе */
59      this._shown = this._initialCount(this._matching('current').length);
60
61      /* Первичный рендер */
62      this._updateAllLinks('current');
63      this._render('current');
64
65      /* Переключение табов через универсальную утилиту */
66      this._destroyTabs = TagankaUtils.bindTabs({
67        tabs:          rootEl.querySelectorAll('.rep-tab'),
68        transitionEls: [this._cardsWrap],
69        delay:         120,
70        onSwitch: (name) => {
71          this._activeTab = name;
72          /* Сбрасываем пагинацию на стартовый показ нового таба */
73          this._shown = this._initialCount(this._matching(name).length);
74          /*
75           * Ссылки меняем ДО рендера — пользователь видит актуальный
76           * текст сразу, без «мигания» старого значения.
77           */
78          this._updateAllLinks(name);
79          this._render(name);
80        },
81      });
82
83      /* «Показать ещё» */
84      if (this._moreBtn) {
85        this._onMore = () => this._showMore();
86        this._moreBtn.addEventListener('click', this._onMore);
87      }
88
89      this._onResize = TagankaUtils.debounce(() => this._fitTitles(), 200);
90      window.addEventListener('resize', this._onResize);
91    }
92
93    /* Карточки, принадлежащие табу (data-categories содержит tabId) */
94    _matching(tabId) {
95      return this._allCards.filter(card =>
96        (card.dataset.categories || '').split(' ').includes(tabId)
97      );
98    }
99
100    /* Стартовое количество: не более STEP, чётное при total ≤ STEP */
101    _initialCount(total) {
102      if (total <= STEP) {
103        if (total <= 1) return total;                 // одинокую карту не прячем
104        return total % 2 === 1 ? total - 1 : total;   // нечётное → вниз до чётного
105      }
106      return STEP;                                     // total > STEP → ровно 6 (чётное)
107    }
108
109    /* Догрузка очередной партии. Последняя партия раскрывает всё. */
110    _showMore() {
111      const total = this._matching(this._activeTab).length;
112      this._shown = Math.min(this._shown + STEP, total);
113      this._render(this._activeTab);
114    }
115
116    _render(tabId) {
117      const matching = this._matching(tabId);
118
119      /* Сначала прячем все карточки и снимаем маркер последней */
120      this._allCards.forEach(card => {
121        card.classList.remove('rep-card--last');
122        card.style.display = 'none';
123      });
124
125      /* Показываем первые this._shown карточек активного таба */
126      const visible = matching.slice(0, this._shown);
127      visible.forEach(card => { card.style.display = 'flex'; });
128
129      if (visible.length) {
130        visible[visible.length - 1].classList.add('rep-card--last');
131      }
132
133      /* rep-row-sep: разделитель после 2-й карточки на десктопе
134         (CSS прячет его — row-gap делает работу, оставлено для совместимости) */
135      this._cardsWrap.querySelectorAll('.rep-row-sep').forEach(s => s.remove());
136      if (TagankaUtils.isDesktop() && visible.length > 2) {
137        const sep = document.createElement('div');
138        sep.className = 'rep-row-sep';
139        sep.setAttribute('aria-hidden', 'true');
140        visible[1].insertAdjacentElement('afterend', sep);
141      }
142
143      /* Видимость кнопки «Показать ещё» */
144      if (this._moreWrap) {
145        const hasMore = this._shown < matching.length;
146        if (hasMore) this._moreWrap.removeAttribute('hidden');
147        else         this._moreWrap.setAttribute('hidden', '');
148      }
149
150      const doFit = () => requestAnimationFrame(() => requestAnimationFrame(() => this._fitTitles()));
151      document.fonts && document.fonts.ready
152        ? document.fonts.ready.then(doFit).catch(doFit)
153        : doFit();
154    }
155
156    _fitTitles() {
157      const sectionW  = this._root.offsetWidth;
158      const w         = window.innerWidth;
159      const fontMin   = 56;
160      const FONT_STEP = 4;
161
162      /* Мобилка (< 768px) — сначала fitCardTitles подгоняет под ширину слова,
163         затем проверяем количество строк: макс 3, fallback 4 при fontMin. */
164      if (w < 768) {
165        const textW = sectionW - 20;
166        const fontStart = 72;
167
168        /* ПРАВКА 09.08: нижняя граница кегля считается от ширины бокса,
169           а не берётся фиксированными 56px.
170
171           Симптом: «ОПУС 48. ОСТРОВСКИЙ» на экране 393px рвался посреди
172           слова без дефиса — «ОСТРОВСКИ / Й». Диагностика на живой
173           странице: инлайновый кегль 56px, то есть ровно fontMin —
174           fitCardTitles упёрся в пол и остановился, а слово в бокс 373px
175           при 56px не влезает (нужно 56.0px впритык). Дальше вступал
176           overflow-wrap: break-word и рубил слово где придётся.
177
178           Замер по всем 37 названиям репертуара (canvas TextMetrics,
179           Pragmatica, letter-spacing -0.025em): при 56px в бокс 373px
180           не помещаются ВЕСТСАЙДСКАЯ (нужно 48.4px), ПОТЕРЯННОМ (54.9),
181           ВАРШАВСКАЯ (55.6) и ОСТРОВСКИЙ (56.0).
182
183           textW / 8 — самое широкое слово репертуара занимает примерно
184           восемь кеглей по ширине, поэтому такой пол гарантированно ниже
185           требуемого на любой ширине экрана, с запасом около 3%:
186             экран 393 → бокс 373 → пол 47 (нужно ≤ 48.4)
187             экран 360 → бокс 340 → пол 43 (нужно ≤ 44.1)
188             экран 320 → бокс 300 → пол 38 (нужно ≤ 38.9)
189           Math.min с прежними 56px оставляет старое поведение на планшетах,
190           где бокс широкий и опускать кегль незачем. Десктопная ветка
191           продолжает использовать fontMin без изменений. */
192        const fontMinMobile = Math.min(fontMin, Math.round(textW / 8));
193
194        TagankaUtils.fitCardTitles(this._cardsWrap, '.rep-card__name', textW, fontStart, fontMinMobile);
195
196        /* Дожимаем по строкам */
197        const MAX_LINES = 3;
198        const MAX_LINES_FALLBACK = 4;
199        this._cardsWrap.querySelectorAll('.rep-card__name').forEach(el => {
200          if (el.offsetWidth === 0) return;
201          let size = parseFloat(el.style.fontSize) || fontStart;
202
203          while (size > fontMinMobile) {
204            const lh = parseFloat(getComputedStyle(el).lineHeight) || size * 0.9;
205            const lines = Math.round(el.scrollHeight / lh);
206            if (lines <= MAX_LINES) break;
207            size -= FONT_STEP;
208            el.style.fontSize = size + 'px';
209          }
210        });
211        return;
212      }
213
214      /* Планшет и десктоп (≥ 768px) — максимальный шрифт при ≤ 3 строках.
215         fontStart масштабируется с шириной экрана, чтобы на широких мониторах
216         длинные названия заполняли строки, а не сжимались в 1 мелкую.
217         Исключение: если при fontMin текст не влезает в 3 строки — допускаем 4. */
218      const fontStart = w >= 1680 ? 108
219                      : w >= 1440 ? 72
220                      :             64;
221
222      const MAX_LINES = 3;
223      const MAX_LINES_FALLBACK = 4;
224      const titles = this._cardsWrap.querySelectorAll('.rep-card__name');
225
226      titles.forEach(el => {
227        if (el.offsetWidth === 0) return;
228
229        el.style.fontSize = fontStart + 'px';
230        let size = fontStart;
231
232        while (size > fontMin) {
233          const lh    = parseFloat(getComputedStyle(el).lineHeight) || size * 0.9;
234          const lines = Math.round(el.scrollHeight / lh);
235          const overflowX = el.scrollWidth > el.offsetWidth + 1;
236          if (lines <= MAX_LINES && !overflowX) break;
237          size -= FONT_STEP;
238          el.style.fontSize = size + 'px';
239        }
240
241        /* Fallback: при fontMin проверяем — если > MAX_LINES, допускаем до 4 строк.
242           Если даже 4 не хватает — оставляем fontMin, overflow-wrap сделает остальное. */
243        if (size <= fontMin) {
244          const lh    = parseFloat(getComputedStyle(el).lineHeight) || fontMin * 0.9;
245          const lines = Math.round(el.scrollHeight / lh);
246          if (lines > MAX_LINES && lines <= MAX_LINES_FALLBACK) {
247            /* 4 строки при минимальном шрифте — ОК, оставляем как есть */
248          }
249        }
250      });
251    }
252
253    _updateAllLinks(tabId) {
254      const cfg = TABS[tabId] || TABS.current;
255
256      if (this._allBtn) {
257        const url = this._allBtn.dataset[`url${tabId.charAt(0).toUpperCase()}${tabId.slice(1)}`];
258        if (url && this._allBtn.tagName === 'A') this._allBtn.href = url;
259        const node = [...this._allBtn.childNodes].find(n => n.nodeType === 3);
260        if (node) node.textContent = cfg.allText;
261        else this._allBtn.prepend(document.createTextNode(cfg.allText));
262      }
263
264      if (this._allHeader) {
265        const url = this._allBtn
266          ? this._allBtn.dataset[`url${tabId.charAt(0).toUpperCase()}${tabId.slice(1)}`]
267          : null;
268        if (url && this._allHeader.tagName === 'A') this._allHeader.href = url;
269        const span = this._allHeader.querySelector('.repertoire__all-header-text');
270        if (span) span.textContent = cfg.allText;
271      }
272    }
273
274    destroy() {
275      this._destroyTabs();
276      if (this._moreBtn && this._onMore) {
277        this._moreBtn.removeEventListener('click', this._onMore);
278      }
279      window.removeEventListener('resize', this._onResize);
280    }
281  }
282
283  /* ══════════════════════════════════════════════════════════════
284     ИНИЦИАЛИЗАЦИЯ
285   ══════════════════════════════════════════════════════════════ */
286  document.addEventListener('DOMContentLoaded', () => {
287    const el = document.querySelector('.repertoire');
288    if (el) new RepertoireBlock(el);
289  });
290
291})();

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.