PageSourceSearch

https://tahdco.com/assets/js/home/schemes-apply.js

js tahdco.com collected 2026-09-30 05:09:02 UTC 36,904 bytes, 826 lines download raw bytes

1/**
2 * OUR SERVICES — Interactive JS (v6 "Five-Panel" Edition)
3 * TAHDCO Website Rewamp — app/views/partials/schemes-apply.php
4 * ============================================================
5 * Drives all 5 panels rendered by the PHP partial:
6 *   1. Schemes (one explorer per tab key)  — left-nav + detail cards
7 *   2. TNCWWB                              — same explorer pattern
8 *   3. Skill Development                   — card grid + live counters
9 *   4. Industries                          — sub-sector filter pills
10 *   5. Tenders                             — intro stats + login card
11 *
12 * Highlights over the previous version:
13 *   - Single "active category" model: only ONE autoplay timer ever
14 *     runs at a time (old version leaked a setInterval per category).
15 *   - Animated count-up for the UPSC "success" counter AND the
16 *     Tenders stats row (parses prefixes/suffixes like "₹240 Cr").
17 *   - Staggered reveal-in animation for Skill cards, Industry detail
18 *     blocks and Tender cards whenever their tab is opened.
19 *   - Industries sub-sector pills wired up (click + arrow-key nav).
20 *   - Swipe-left / swipe-right on touch devices to move between
21 *     scheme detail cards.
22 *   - Full keyboard support + correct aria-selected/aria-hidden sync.
23 *   - Respects prefers-reduced-motion (and reacts live if it changes).
24 * ============================================================
25 */
26(function () {
27    'use strict';
28
29    /* ============================================================
30       0. CONFIG, SELECTORS & STATE
31    ============================================================ */
32    const CONFIG = {
33        autoplayDelay:   6000,  // ms between auto-advances in a scheme explorer
34        colorDuration:   550,   // ms accent colour cross-fade
35        counterDuration: 1800,  // ms count-up animation
36        revealStep:      70,    // ms stagger between sibling cards
37        revealDuration:  600,   // ms per-item reveal animation
38        swipeThreshold:  48,    // px horizontal distance to count as a swipe
39    };
40
41    // Fallback accent palette — mirrors $schemeColorPalette in the PHP partial
42    const PALETTE = ['#1565c0', '#0d7a6b', '#b45309', '#6d28d9', '#be185d', '#15803d'];
43
44    const SEL = {
45        section:     '.sa-section',
46        tabsWrap:    '.sa-tabs-wrap',
47        catTabs:     '.sa-category-tabs',
48        catBtn:      '.sa-cat-btn',
49
50        explorer:    '.sa-explorer',
51        schemeNav:   '.sa-scheme-nav',
52        schemeBtn:   '.sa-scheme-btn',
53        detailCard:  '.sa-detail-card',
54        navProgress: '.sa-nav-progress',
55        detailWrap:  '.sa-detail-wrap',
56
57        skillPanel:  '.sa-skill-panel',
58        skCard:      '.sa-sk-card',
59        skCount:     '.sa-sk-success__count',
60
61        indPanel:    '.sa-ind-panel',
62        indFilter:   '.sa-ind-filter',
63        indDetail:   '.sa-ind-detail',
64        indModel:    '.sa-ind-model',
65        indIntro:    '.sa-ind-intro',
66
67        tenderPanel: '.sa-tenders-panel',
68        tenderHero:  '.sa-tender-hero',
69        tenderStat:  '.sa-tender-stat-item',
70        tenderNum:   '.sa-tender-stat-item__num',
71        tenderCard:  '.sa-tender-card',
72    };
73
74    let REDUCED_MOTION = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
75    if (window.matchMedia) {
76        const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
77        const onMqChange = function (e) { REDUCED_MOTION = e.matches; };
78        if (mq.addEventListener) mq.addEventListener('change', onMqChange);
79        else if (mq.addListener) mq.addListener(onMqChange); // older Safari
80    }
81
82    const autoplayTimers = {};   // { [category]: intervalId }
83    const colorAnims     = {};   // { [category]: rafId }
84    let   activeCategory = null;
85    let   sectionVisible = true;
86
87    /* ============================================================
88       1. GENERIC HELPERS
89    ============================================================ */
90    function qsa(selector, scope) { return Array.prototype.slice.call((scope || document).querySelectorAll(selector)); }
91    function q(selector, scope)   { return (scope || document).querySelector(selector); }
92
93    function debounce(fn, wait) {
94        let t;
95        return function () {
96            const args = arguments;
97            clearTimeout(t);
98            t = setTimeout(function () { fn.apply(null, args); }, wait);
99        };
100    }
101
102    function colorForIndex(i) { return PALETTE[i % PALETTE.length]; }
103
104    function hexToRgb(hex) {
105        if (!hex) return null;
106        hex = String(hex).trim().replace('#', '');
107        if (hex.length === 3) hex = hex.split('').map(function (c) { return c + c; }).join('');
108        if (hex.length !== 6) return null;
109        const num = parseInt(hex, 16);
110        if (isNaN(num)) return null;
111        return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
112    }
113
114    function getCssColor(el) {
115        if (!el) return null;
116        const v = getComputedStyle(el).getPropertyValue('--scheme-color');
117        return v ? v.trim() : null;
118    }
119
120    function explorerFor(category) { return q(SEL.explorer + '[data-sa-panel="' + category + '"]'); }
121
122    /**
123     * Centers `target` inside `container`'s own horizontal scrollbar by
124     * adjusting container.scrollLeft directly.
125     *
126     * Deliberately NOT using Element.scrollIntoView() here — that API walks
127     * every scrollable ancestor up to (and including) the browser window
128     * and tries to "center" the target in each one. On this page that meant
129     * clicking a tab on the right side of the bar (Skill Development /
130     * Industries / Tenders) would also re-center the whole WINDOW
131     * horizontally, visibly shifting the entire section left. scrollBy()
132     * only ever touches the element it's called on, so it can never escape
133     * to the window.
134     */
135    function scrollPillIntoView(container, target) {
136        if (!container || !target) return;
137        // Only meaningful if this container actually scrolls horizontally.
138        if (container.scrollWidth <= container.clientWidth + 1) return;
139
140        const cRect = container.getBoundingClientRect();
141        const tRect = target.getBoundingClientRect();
142        const delta = (tRect.left + tRect.width / 2) - (cRect.left + cRect.width / 2);
143        if (Math.abs(delta) < 1) return;
144
145        container.scrollBy({ left: delta, behavior: REDUCED_MOTION ? 'auto' : 'smooth' });
146    }
147
148    /* ============================================================
149       2. ACCENT COLOUR (per explorer)
150    ============================================================ */
151    function setAccentInstant(explorer, hex) {
152        const rgb = hexToRgb(hex);
153        if (!rgb || !explorer) return;
154        explorer.style.setProperty('--sa-r', rgb[0]);
155        explorer.style.setProperty('--sa-g', rgb[1]);
156        explorer.style.setProperty('--sa-b', rgb[2]);
157    }
158
159    function animateAccent(explorer, hex) {
160        const target = hexToRgb(hex);
161        if (!target || !explorer) return;
162
163        if (REDUCED_MOTION) { setAccentInstant(explorer, hex); return; }
164
165        const cs = getComputedStyle(explorer);
166        const start = [
167            parseFloat(cs.getPropertyValue('--sa-r')) || target[0],
168            parseFloat(cs.getPropertyValue('--sa-g')) || target[1],
169            parseFloat(cs.getPropertyValue('--sa-b')) || target[2],
170        ];
171
172        const key = explorer.dataset.saPanel || 'default';
173        if (colorAnims[key]) cancelAnimationFrame(colorAnims[key]);
174
175        const t0 = performance.now();
176        function step(now) {
177            const t = Math.min(1, (now - t0) / CONFIG.colorDuration);
178            const e = 1 - Math.pow(1 - t, 3);
179            explorer.style.setProperty('--sa-r', Math.round(start[0] + (target[0] - start[0]) * e));
180            explorer.style.setProperty('--sa-g', Math.round(start[1] + (target[1] - start[1]) * e));
181            explorer.style.setProperty('--sa-b', Math.round(start[2] + (target[2] - start[2]) * e));
182            if (t < 1) colorAnims[key] = requestAnimationFrame(step);
183            else delete colorAnims[key];
184        }
185        colorAnims[key] = requestAnimationFrame(step);
186    }
187
188    /* ============================================================
189       3. SCHEME EXPLORER (Schemes tabs + TNCWWB) — colours, dots, bars
190    ============================================================ */
191    function ensureSwatch(btn) {
192        if (q('.sa-scheme-btn__swatch', btn)) return;
193        const swatch = document.createElement('span');
194        swatch.className = 'sa-scheme-btn__swatch';
195        swatch.setAttribute('aria-hidden', 'true');
196        btn.insertBefore(swatch, btn.firstChild);
197    }
198    function ensureBar(btn) {
199        let bar = q('.sa-scheme-btn__bar', btn);
200        if (!bar) {
201            bar = document.createElement('span');
202            bar.className = 'sa-scheme-btn__bar';
203            bar.setAttribute('aria-hidden', 'true');
204            btn.appendChild(bar);
205        }
206        bar.style.setProperty('--sa-autoplay-duration', CONFIG.auto
206playDelay + 'ms');
207    }
208
209    function assignColors() {
210        qsa(SEL.explorer).forEach(function (explorer) {
211            if (!q(SEL.schemeNav, explorer)) return; // skip skill / industries / tenders panels
212
213            const category = explorer.dataset.saPanel;
214            const btns  = qsa(SEL.schemeBtn + '[data-sa-category="' + category + '"]', explorer);
215            const cards = qsa(SEL.detailCard, explorer);
216            if (!btns.length) return;
217
218            const colors = btns.map(function (btn, i) {
219                const color = btn.dataset.saColor || (cards[i] && cards[i].dataset.saColor) || colorForIndex(i);
220                btn.style.setProperty('--scheme-color', color);
221                if (cards[i]) cards[i].style.setProperty('--scheme-color', color);
222                ensureSwatch(btn);
223                ensureBar(btn);
224                return color;
225            });
226
227            let activeIndex = cards.findIndex(function (c) { return c.classList.contains('is-active'); });
228            if (activeIndex < 0) activeIndex = 0;
229            setAccentInstant(explorer, colors[activeIndex] || colorForIndex(0));
230        });
231    }
232
233    function buildProgressDots(explorer) {
234        const category = explorer.dataset.saPanel;
235        const nav  = q(SEL.schemeNav, explorer);
236        const btns = qsa(SEL.schemeBtn + '[data-sa-category="' + category + '"]', explorer);
237        if (!nav || btns.length < 2) return;
238
239        const old = q(SEL.navProgress, explorer);
240        if (old) old.remove();
241
242        const progress = document.createElement('div');
243        progress.className = 'sa-nav-progress';
244        progress.setAttribute('aria-hidden', 'true');
245
246        btns.forEach(function (btn, i) {
247            const dot = document.createElement('span');
248            dot.className = 'sa-nav-dot' + (btn.classList.contains('is-active') ? ' is-active' : '');
249            dot.style.setProperty('--scheme-color', getCssColor(btn) || colorForIndex(i));
250            progress.appendChild(dot);
251        });
252        nav.appendChild(progress);
253    }
254
255    function updateProgressDots(explorer, activeIndex) {
256        qsa('.sa-nav-dot', explorer).forEach(function (dot, i) {
257            dot.classList.toggle('is-active', i === activeIndex);
258        });
259    }
260
261    function restartAutoplayBar(activeBtn) {
262        if (!activeBtn) return;
263        const category = activeBtn.dataset.saCategory;
264        qsa(SEL.schemeBtn + '[data-sa-category="' + category + '"]').forEach(function (b) {
265            const bar = q('.sa-scheme-btn__bar', b);
266            if (bar) bar.classList.remove('is-running');
267        });
268        if (REDUCED_MOTION) return;
269        const bar = q('.sa-scheme-btn__bar', activeBtn);
270        if (!bar) return;
271        void bar.offsetWidth; // force reflow so the animation restarts cleanly
272        bar.classList.add('is-running');
273    }
274
275    function pauseAutoplayBars(category) {
276        qsa(SEL.schemeBtn + '[data-sa-category="' + category + '"]').forEach(function (b) {
277            const bar = q('.sa-scheme-btn__bar', b);
278            if (bar) bar.classList.remove('is-running');
279        });
280    }
281
282    /* ── Switching the active scheme inside one explorer ─────────── */
283    function switchScheme(category, targetIndex) {
284        const explorer = explorerFor(category);
285        if (!explorer) return;
286
287        const schemeBtns = qsa(SEL.schemeBtn + '[data-sa-category="' + category + '"]', explorer);
288        const cards      = qsa(SEL.detailCard, explorer);
289        if (!cards[targetIndex]) return;
290
291        const currentIndex = cards.findIndex(function (c) { return c.classList.contains('is-active'); });
292        if (currentIndex === targetIndex) return;
293
294        if (currentIndex > -1) {
295            const currentCard = cards[currentIndex];
296            currentCard.classList.add('is-leaving');
297            currentCard.classList.remove('is-active');
298            currentCard.setAttribute('aria-hidden', 'true');
299
300            const cleanup = function (e) {
301                if (e && e.propertyName !== 'opacity') return;
302                currentCard.classList.remove('is-leaving');
303                currentCard.removeEventListener('transitionend', cleanup);
304            };
305            currentCard.addEventListener('transitionend', cleanup);
306            setTimeout(cleanup, REDUCED_MOTION ? 0 : 480); // safety net
307        }
308
309        const targetCard = cards[targetIndex];
310        const activate = function () {
311            targetCard.classList.add('is-active');
312            targetCard.setAttribute('aria-hidden', 'false');
313        };
314        if (REDUCED_MOTION) activate();
315        else setTimeout(activate, 50);
316
317        schemeBtns.forEach(function (btn, i) {
318            const isActive = i === targetIndex;
319            btn.classList.toggle('is-active', isActive);
320            btn.setAttribute('aria-selected', isActive ? 'true' : 'false');
321        });
322
323        updateProgressDots(explorer, targetIndex);
324        animateAccent(explorer, getCssColor(targetCard) || colorForIndex(targetIndex));
325        if (schemeBtns[targetIndex]) restartAutoplayBar(schemeBtns[targetIndex]);
326    }
327
328    function initSchemeBtns() {
329        qsa(SEL.schemeBtn).forEach(function (btn) {
330            btn.addEventListener('click', function () {
331                const explorer = btn.closest(SEL.explorer);
332                const category = btn.dataset.saCategory;
333                const siblings = qsa(SEL.schemeBtn + '[data-sa-category="' + category + '"]', explorer || document);
334                const index = siblings.indexOf(btn);
335
336                stopAutoplay(category);
337                switchScheme(category, index);
338                if (activeCategory === category) startAutoplay(category);
339            });
340
341            // Up/Down arrow roving navigation within a scheme nav list
342            btn.addEventListener('keydown', function (e) {
343                if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); btn.click(); return; }
344                if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
345
346                e.preventDefault();
347                const explorer = btn.closest(SEL.explorer);
348                const category = btn.dataset.saCategory;
349                const siblings = qsa(SEL.schemeBtn + '[data-sa-category="' + category + '"]', explorer || document);
350                const i = siblings.indexOf(btn);
351                const nextI = e.key === 'ArrowDown' ? (i + 1) % siblings.length : (i - 1 + siblings.length) % siblings.length;
352                siblings[nextI].focus();
353                siblings[nextI].click();
354            });
355        });
356    }
357
358    /* ============================================================
359       4. AUTOPLAY — exactly one running timer at any moment
360    ============================================================ */
361    function startAutoplay(category) {
362        if (!category) return;
363        stopAutoplay(category);
364        if (REDUCED_MOTION || !sectionVisible) return;
365
366        const explorer = explorerFor(category);
367        if (!explorer) return;
368        const cards = qsa(SEL.detailCard, explorer);
369        if (cards.length <= 1) return;
370
371        const activeBtn = q(SEL.schemeBtn + '.is-active[data-sa-category="' + category + '"]', explorer);
372        if (activeBtn) restartAutoplayBar(activeBtn);
373
374        autoplayTimers[category] = setInterval(function () {
375            const current = cards.findIndex(function (c) { return c.classList.contains('is-active'); });
376            switchScheme(category, (current + 1) % cards.length);
377        }
377, CONFIG.autoplayDelay);
378    }
379
380    function stopAutoplay(category) {
381        if (!category) return;
382        if (autoplayTimers[category]) { clearInterval(autoplayTimers[category]); delete autoplayTimers[category]; }
383        pauseAutoplayBars(category);
384    }
385
386    function setActiveCategory(category) {
387        if (activeCategory && activeCategory !== category) stopAutoplay(activeCategory);
388        activeCategory = category;
389        startAutoplay(category);
390    }
391
392    function initPauseOnHover() {
393        qsa(SEL.explorer).forEach(function (explorer) {
394            if (!q(SEL.schemeNav, explorer)) return;
395            const category = explorer.dataset.saPanel;
396
397            explorer.addEventListener('mouseenter', function () { stopAutoplay(category); });
398            explorer.addEventListener('focusin',    function () { stopAutoplay(category); });
399            explorer.addEventListener('mouseleave', function () { if (activeCategory === category) startAutoplay(category); });
400            explorer.addEventListener('focusout',   function (e) {
401                if (activeCategory === category && !explorer.contains(e.relatedTarget)) startAutoplay(category);
402            });
403        });
404    }
405
406    function initVisibilityObserver() {
407        if (!window.IntersectionObserver) return;
408        const section = q(SEL.section);
409        if (!section) return;
410
411        new IntersectionObserver(function (entries) {
412            entries.forEach(function (entry) {
413                sectionVisible = entry.isIntersecting;
414                if (sectionVisible) startAutoplay(activeCategory);
415                else stopAutoplay(activeCategory);
416            });
417        }, { threshold: 0.25 }).observe(section);
418    }
419
420    /* ============================================================
421       5. SWIPE GESTURES (touch) — move between scheme detail cards
422    ============================================================ */
423    function initSwipeGestures() {
424        qsa(SEL.detailWrap).forEach(function (wrap) {
425            let startX = 0, startY = 0, tracking = false;
426
427            wrap.addEventListener('touchstart', function (e) {
428                const t = e.touches[0];
429                startX = t.clientX; startY = t.clientY; tracking = true;
430            }, { passive: true });
431
432            wrap.addEventListener('touchend', function (e) {
433                if (!tracking) return;
434                tracking = false;
435                const t = e.changedTouches[0];
436                const dx = t.clientX - startX, dy = t.clientY - startY;
437                if (Math.abs(dx) < CONFIG.swipeThreshold || Math.abs(dx) < Math.abs(dy)) return;
438
439                const explorer = wrap.closest(SEL.explorer);
440                if (!explorer) return;
441                const category = explorer.dataset.saPanel;
442                const cards = qsa(SEL.detailCard, explorer);
443                const current = cards.findIndex(function (c) { return c.classList.contains('is-active'); });
444                if (current < 0) return;
445
446                let next = dx < 0 ? current + 1 : current - 1;
447                if (next < 0) next = cards.length - 1;
448                if (next >= cards.length) next = 0;
449
450                stopAutoplay(category);
451                switchScheme(category, next);
452                if (activeCategory === category) startAutoplay(category);
453            }, { passive: true });
454        });
455    }
456
457    /* ============================================================
458       6. NUMBER COUNT-UP (Skill success counter + Tender stats)
459    ============================================================ */
460    function parseNumberText(text) {
461        const match = String(text || '').trim().match(/^([^\d]*)([\d,]+(?:\.\d+)?)(.*)$/);
462        if (!match) return null;
463        const numStr = match[2];
464        return {
465            prefix:   match[1],
466            suffix:   match[3],
467            value:    parseFloat(numStr.replace(/,/g, '')),
468            decimals: (numStr.split('.')[1] || '').length,
469            useComma: numStr.indexOf(',') !== -1,
470        };
471    }
472
473    function formatNumber(num, decimals, useComma) {
474        let str = num.toFixed(decimals);
475        if (useComma) {
476            const parts = str.split('.');
477            parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
478            str = parts.join('.');
479        }
480        return str;
481    }
482
483    function prepareCounter(el) {
484        if (el.dataset.targetCaptured === 'true') return;
485        el.dataset.target = el.dataset.target !== undefined ? el.dataset.target : el.textContent.trim();
486        el.dataset.targetCaptured = 'true';
487    }
488
489    function animateCounter(el) {
490        if (!el || el.dataset.counting === 'true') return;
491        const parsed = parseNumberText(el.dataset.target);
492        if (!parsed) return;
493
494        const render = function (value) {
495            el.textContent = parsed.prefix + formatNumber(value, parsed.decimals, parsed.useComma) + parsed.suffix;
496        };
497
498        if (REDUCED_MOTION) { render(parsed.value); return; }
499
500        el.dataset.counting = 'true';
501        render(0);
502        const t0 = performance.now();
503        function step(now) {
504            const t = Math.min(1, (now - t0) / CONFIG.counterDuration);
505            const eased = 1 - Math.pow(1 - t, 3);
506            render(parsed.value * eased);
507            if (t < 1) requestAnimationFrame(step);
508            else { render(parsed.value); el.dataset.counting = 'false'; }
509        }
510        requestAnimationFrame(step);
511    }
512
513    function runCounters(scopeEl, selector) {
514        qsa(selector, scopeEl).forEach(function (el) {
515            prepareCounter(el);
516            animateCounter(el);
517        });
518    }
519
520    /* ============================================================
521       7. STAGGERED REVEAL — fades/lifts a group of cards into view
522    ============================================================ */
523    function revealStagger(scopeEl, selector) {
524        if (REDUCED_MOTION) return;
525        qsa(selector, scopeEl).forEach(function (item, i) {
526            item.style.transition = 'none';
527            item.style.opacity = '0';
528            item.style.transform = 'translateY(18px)';
529            void item.offsetWidth; // force reflow
530            const delay = i * CONFIG.revealStep;
531            item.style.transition =
532                'opacity ' + CONFIG.revealDuration + 'ms cubic-bezier(.22,1,.36,1) ' + delay + 'ms, ' +
533                'transform ' + CONFIG.revealDuration + 'ms cubic-bezier(.22,1,.36,1) ' + delay + 'ms';
534            requestAnimationFrame(function () {
535                item.style.opacity = '1';
536                item.style.transform = 'none';
537            });
538        });
539    }
540
541    /* ============================================================
542       8. INDUSTRIES PANEL — sub-sector filter pills
543    ============================================================ */
544    function initIndustriesFilters() {
545        const panel = q(SEL.indPanel);
546        if (!panel) return;
547
548        const filters = qsa(SEL.indFilter, panel);
549        const details = qsa(SEL.indDetail, panel);
550        if (!filters.length) return;
551
552        filters.forEach(function (btn) {
553            btn.setAttribute('aria-pressed', btn.classList.contains('is-active') ? 'true' : 'false');
554        });
555
556        function activate(key) {
557            filters.forEach(function (f) {
558                const match = f.dataset.indKey === key;
559                f.classList.toggle('is-active', match);
560                f.setAttribute('aria-pressed', match ? 'true' : 'false');
561            });
562            details.forEach(function (d) { d.classList.toggle('is-active', d.dataset.indDetail === key); });
563
564            const activeDetail = details.find(function (d) { return d.dataset.indDetail === key; });
565            if (activeDetail) {
566                revealStagger(activeDetail, SEL.indModel);
567                revealStagger(panel, SEL.indIntro);
568            }
569        }
570
571        filters.forEach(function (btn, i) {
572            btn.addEventListener('click', function () {
573                activate(btn.dataset.indKey);
574                // NOTE: deliberately no scrollIntoView here — .sa-ind-filters wraps
575                // (no horizontal overflow), so there is nothing to scroll, and
576                // scrollIntoView can otherwise bubble up and shift the whole page.
577            });
578            btn.addEventListener('keydown', function (e) {
579                if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
580                e.preventDefault();
581                const nextI = e.key === 'ArrowRight' ? (i + 1) % filters.length : (i - 1 + filters.length) % filters.length;
582                filters[nextI].focus();
583                activate(filters[nextI].dataset.indKey);
584            });
585        });
586    }
587
588    /* ============================================================
589       9. PANEL ACTIVATION — extras beyond simple show/hide
590    ============================================================ */
591    function activatePanelExtras(category) {
592        const explorer = explorerFor(category);
593        if (!explorer) return;
594
595        if (explorer.classList.contains('sa-skill-panel')) {
596            return; // stp render handles its own transitions
597        }
598
599        if (explorer.classList.contains('sa-ind-panel')) {
600            const activeDetail = q(SEL.indDetail + '.is-active', explorer) || q(SEL.indDetail, explorer);
601            if (activeDetail) revealStagger(activeDetail, SEL.indModel);
602            revealStagger(explorer, SEL.indIntro);
603            return;
604        }
605
606        if (explorer.classList.contains('sa-tenders-panel')) {
607            revealStagger(explorer, SEL.tenderStat);
608            revealStagger(explorer, SEL.tenderCard);
609            runCounters(explorer, SEL.tenderNum);
610            return;
611        }
612
613        // Schemes / TNCWWB style explorer
614        if (q(SEL.schemeNav, explorer)) setActiveCategory(category);
615    }
616
617    /* ============================================================
618       10. CATEGORY TABS (top pill bar)
619    ============================================================ */
620    function ensureTabIndicator(tabsEl) {
621        let indicator = q('.sa-cat-indicator', tabsEl);
622        if (!indicator) {
623            indicator = document.createElement('span');
624            indicator.className = 'sa-cat-indicator';
625            indicator.setAttribute('aria-hidden', 'true');
626            tabsEl.insertBefore(indicator, tabsEl.firstChild);
627        }
628        return indicator;
629    }
630
631    function updateTabIndicator(tabsEl) {
632        const indicator = ensureTabIndicator(tabsEl);
633        const active = q(SEL.catBtn + '.is-active', tabsEl);
634        if (!active) { indicator.style.width = '0px'; return; }
635        indicator.style.width = active.offsetWidth + 'px';
636        indicator.style.transform = 'translateX(' + active.offsetLeft + 'px)';
637    }
638
639    function goToCategory(cat, btn) {
640        if (!cat) return;
641
642        qsa(SEL.catBtn).forEach(function (b) { b.classList.toggle('is-active', b.dataset.saCategory === cat); });
643        qsa(SEL.explorer).forEach(function (panel) { panel.classList.toggle('is-active', panel.dataset.saPanel === cat); });
644
645        const tabsEl = (btn && btn.closest(SEL.catTabs)) || q(SEL.catTabs);
646        if (tabsEl) updateTabIndicator(tabsEl);
647
648        activatePanelExtras(cat);
649
650        // Keep the clicked tab visible inside the pill bar's own scrollbar
651        // ONLY (mobile, when the bar overflows) — never the window. See
652        // scrollPillIntoView() for why scrollIntoView() is avoided here.
653        const wrapEl = (btn && btn.closest(SEL.tabsWrap)) || q(SEL.tabsWrap);
654        if (btn && wrapEl) scrollPillIntoView(wrapEl, btn);
655    }
656
657    function initCategoryTabs() {
658        qsa(SEL.catTabs).forEach(function (tabsEl) { ensureTabIndicator(tabsEl); updateTabIndicator(tabsEl); });
659
660        const allBtns = qsa(SEL.catBtn);
661        allBtns.forEach(function (btn, i) {
662            btn.addEventListener('click', function () { goToCategory(btn.dataset.saCategory, btn); });
663
664            btn.addEventListener('keydown', function (e) {
665                if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
666                e.preventDefault();
667                const nextI = e.key === 'ArrowRight' ? (i + 1) % allBtns.length : (i - 1 + allBtns.length) % allBtns.length;
668                allBtns[nextI].focus();
669                goToCategory(allBtns[nextI].dataset.saCategory, allBtns[nextI]);
670            });
671        });
672    }
673
674    /* ============================================================
675       11. BOOT
676    ============================================================ */
677    /* ── Skill Development panel (embedded /skilldev design) ── */
678    let STP_CATEGORIES = [];
679    let STP_BY_ID = {};
680
681    function stpEscapeHtml(str) {
682        if (str === undefined || str === null) return '';
683        return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
684    }
685    function stpEscapeAttr(str) { return stpEscapeHtml(str).replace(/"/g, '&quot;'); }
686    function stpIsExternal(url) { return !!url && /^https?:\/\//i.test(url); }
687    function stpExtAttr(url) { return stpIsExternal(url) ? ' target="_blank" rel="noopener noreferrer"' : ''; }
688
689    function stpRender(cat) {
690        const detail = document.getElementById('stpDetailHome');
691        if (!detail) return;
692
693        const tagsHtml = (cat.tags || []).map(function (t) {
694            return '<span class="stp-tag">' + stpEscapeHtml(t) + '</span>';
695        }).join('');
696
697        const statsHtml = (cat.stats || []).map(function (s) {
698            return '<div class="stp-stat">' +
699                '<span class="stp-stat__icon"><i class="bi ' + s.icon + '"></i></span>' +
700                '<div>' +
701                    '<div class="stp-stat__value">' + stpEscapeHtml(s.value) + '</div>' +
702                    '<div class="stp-stat__label">' + stpEscapeHtml(s.label) + '</div>' +
703                '</div></div>';
704        }).join('');
705
706        const programmesHtml = (cat.programmes || []).map(function (p) {
707            return '<div class="stp-programme">' +
708                '<span class="stp-programme__icon"><i class="bi bi-mortarboard-fill"></i></span>' +
709                '<div class="stp-programme__body">' +
710                    '<h4 class="stp-programme__title">' + stpEscapeHtml(p.title) + '</h4>' +
711                    '<p class="stp-programme__desc">' + stpEscapeHtml(p.description) + '</p>' +
712                '</div>' +
713                '<div class="stp-programme__actions">' +
714                    '<a class="stp-action" href="' + stpEscapeAttr(p.view_link) + '"' + stpExtAttr(p.view_link) + '><i class="bi bi-eye"></i>View</a>' +
715                    '<a class="stp-action" href="' + stpEscapeAttr(p.brochure_link) + '"' + stpExtAttr(p.brochure_link) + '><i class="bi bi-file-earmark-pdf"></i>Brochure</a>' +
716                    '<a class="stp-action" href="' + stpEscapeAttr(p.video_link) + '"' + stpExtAttr(p.video_link) + '><i class="bi bi-play-circle"></i>Video</a>' +
717                    '<a class="stp-programme__apply" href="' + stpEscapeAttr(p.apply_link) + '"' + stpExtAttr(p.apply_link) + '>Apply Online</a>' +
718                '</div></div>';
719        }).join('');
720
721        const assetBase = window.STP_ASSET_BASE_HOME || '';
722
723        detail.innerHTML =
724            '<div class="stp-detail__inner">' +
725                '<div class="stp-detail__top">' +
726                    '<div class="stp-detail__image">' +
727                        '<img src="' + assetBase + cat.image + '" alt="' + stpEscapeHtml(cat.label) + '" ' +
728                        'onerror="this.parentElement.style.background=\'color-mix(in srgb, var(--stp-accent) 15%, white)\'; this.remove();">' +
729                    '</div>' +
730                    '<div class="stp-detail__info">' +
731                        '<div class="stp-detail__heading">' +
732                            '<span class="stp-detail__icon"><i class="bi ' + cat.icon + '"></i></span>' +
733                            '<h3>' + stpEscapeHtml(cat.label) + '</h3>' +
734                        '</div>' +
735                        '<p class="stp-detail__desc">' + stpEscapeHtml(cat.description) + '</p>' +
736                        '<div class="stp-detail__tags">' + tagsHtml + '</div>' +
737                        '<div class="stp-detail__stats">' + statsHtml + '</div>' +
738                    '</div>' +
739                '</div>' +
740                '<h4 class="stp-programmes__title">Available Programmes in ' + stpEscapeHtml(cat.label) + '</h4>' +
741                programmesHtml +
742            '</div>';
743    }
744
745    function switchSkillCategory(catId) {
746        const cat = STP_BY_ID[catId];
747        const detail = document.getElementById('stpDetailHome');
748        if (!cat || !detail) return;
749
750        qsa('.stp-category', q(SEL.skillPanel)).forEach(function (btn) {
751            btn.classList.toggle('is-active', btn.getAttribute('data-category-id') === catId);
752        });
753
754        detail.style.setProperty('--stp-accent', cat.color);
755        detail.classList.add('is-fading');
756
757        window.setTimeout(function () {
758            stpRender(cat);
759            detail.classList.remove('is-fading');
760        }, 160);
761    }
762
763    function initSkillPanel() {
764        const panel = q(SEL.skillPanel);
765        if (!panel) return;
766
767        const dataEl = document.getElementById('stpDataHome');
768        if (!dataEl) return;
769
770        try {
771            STP_CATEGORIES = JSON.parse(dataEl.textContent || '[]');
772        } catch (e) {
773            STP_CATEGORIES = [];
774        }
775        STP_BY_ID = {};
776        STP_CATEGORIES.forEach(function (c) { STP_BY_ID[c.id] = c; });
777
778        qsa('.stp-category', panel).forEach(function (btn) {
779            btn.addEventListener('click', function () {
780                switchSkillCategory(btn.getAttribute('data-category-id'));
781            });
782        });
783
784        // Initial render
785        const initialId = window.STP_DEFAULT_CATEGORY_HOME || (STP_CATEGORIES[0] && STP_CATEGORIES[0].id);
786        if (initialId && STP_BY_ID[initialId]) {
787            const detail = document.getElementById('stpDetailHome');
788            if (detail) detail.style.setProperty('--stp-accent', STP_BY_ID[initialId].color);
789            stpRender(STP_BY_ID[initialId]);
790        }
791    }
792
793    function init() {
794        assignColors();
795        initSchemeBtns();
796        initCategoryTabs();
797        initIndustriesFilters();
798        initSkillPanel();
799        initSwipeGestures();
800
801        qsa(SEL.explorer).forEach(function (explorer) {
802            if (q(SEL.schemeNav, explorer)) buildProgressDots(explorer);
803        });
804
805        initPauseOnHover();
806        initVisibilityObserver();
807
808        // Run the extras (counters / reveal / autoplay) for whichever panel
809        // the server already rendered as active.
810        const initialBtn = q(SEL.catBtn + '.is-active') || q(SEL.catBtn);
811        const initialCategory = initialBtn ? initialBtn.dataset.saCategory : (q(SEL.explorer) || {}).dataset && q(SEL.explorer).dataset.saPanel;
812        requestAnimationFrame(function () { activatePanelExtras(initialCategory); });
813
814        window.addEventListener('resize', debounce(function () {
815            qsa(SEL.catTabs).forEach(updateTabIndicator);
816        }, 150));
817
818        window.addEventListener('load', function () {
819            qsa(SEL.catTabs).forEach(updateTabIndicator);
820        });
821    }
822
823    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
824    else init();
825
826})();

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.