PageSourceSearch

https://entrevue.fr/wp-content/themes/entrevue/assets/js/ent-header.js?ver=1.3.25.202609241164

js entrevue.fr collected 2026-09-24 19:39:24 UTC 24,422 bytes, 544 lines download raw bytes

1(() => {
2  const header = document.querySelector('.site-header');
3  const widget = document.querySelector('[data-ent-header-widget]');
4  const desktopMq = window.matchMedia('(min-width: 1024px)');
5
6  /* =========================================
7     Nav overflow — collapse items that don't fit
8     into a "Plus ▾" dropdown (desktop only)
9     ========================================= */
10  const menu = document.getElementById('primary-menu');
11  let queueReflow = () => {};
12
13  if (menu) {
14    const moreLi = document.createElement('li');
15    moreLi.className = 'ent-nav-more';
16    moreLi.hidden = true;
17
18    const moreBtn = document.createElement('button');
19    moreBtn.type = 'button';
20    moreBtn.setAttribute('aria-expanded', 'false');
21    moreBtn.setAttribute('aria-haspopup', 'true');
22    moreBtn.innerHTML = 'Plus <span aria-hidden="true">▾</span>';
23
24    const moreMenu = document.createElement('ul');
25    moreMenu.className = 'ent-nav-more__menu';
26    moreMenu.hidden = true;
27
28    moreLi.append(moreBtn, moreMenu);
29    menu.appendChild(moreLi);
30
31    const closeMore = () => {
32      moreLi.classList.remove('is-open');
33      moreMenu.hidden = true;
34      moreBtn.setAttribute('aria-expanded', 'false');
35    };
36
37    moreBtn.addEventListener('click', () => {
38      const open = moreLi.classList.toggle('is-open');
39      moreMenu.hidden = !open;
40      moreBtn.setAttribute('aria-expanded', String(open));
41    });
42    document.addEventListener('click', (event) => {
43      if (!moreLi.contains(event.target)) closeMore();
44    });
45    document.addEventListener('keydown', (event) => {
46      if (event.key === 'Escape') closeMore();
47    });
48
49    // In the sticky bar we always show at most 12 items, the rest go under "Plus"
50    const STICKY_MAX_ITEMS = 12;
51
52    const reflowNav = () => {
53      // restore every collapsed item to its original spot
54      while (moreMenu.firstElementChild) {
55        menu.insertBefore(moreMenu.firstElementChild, moreLi);
56      }
57      moreLi.hidden = true;
58
59      if (!desktopMq.matches) return; // mobile menu handles itself
60
61      // sticky: hard cap at 12 visible items
62      if (header && header.classList.contains('is-sticky')) {
63        const items = [...menu.children].filter(
64          (li) => li !== moreLi && !li.classList.contains('burger-close')
65        );
66        for (let i = items.length - 1; i >= STICKY_MAX_ITEMS; i--) {
67          moreLi.hidden = false;
68          moreMenu.insertBefore(items[i], moreMenu.firstChild);
69        }
70      }
71
72      // then collapse trailing items until the row fits
73      let guard = menu.children.length;
74      while (guard-- > 0 && menu.scrollWidth > menu.clientWidth + 1) {
75        const item = moreLi.previousElementSibling;
76        if (!item || item.classList.contains('burger-close')) break;
77        moreLi.hidden = false;
78        moreMenu.insertBefore(item, moreMenu.firstChild);
79      }
80
81      if (!moreMenu.firstElementChild) moreLi.hidden = true;
82    };
83
84    let reflowRaf = 0;
85    queueReflow = () => {
86      cancelAnimationFrame(reflowRaf);
87      reflowRaf = requestAnimationFrame(reflowNav);
88    };
89
90    window.addEventListener('resize', queueReflow);
91    if (desktopMq.addEventListener) desktopMq.addEventListener('change', queueReflow);
92    if (document.fonts && document.fonts.ready) document.fonts.ready.then(queueReflow);
93    queueReflow();
94  }
95
96  /* =========================================
97     Sticky header
98     ========================================= */
99  if (header) {
100    let ticking = false;
101    const root = document.documentElement;
102    const syncSticky = () => {
103      const wasSticky = header.classList.contains('is-sticky');
104      // Hysteresis: collapse past 24px, expand again only near the top, so small
105      // wiggles around the threshold don't re-toggle the bar (each toggle re-lays
106      // out the header row, which is still a small layout shift).
107      const sticky = wasSticky ? window.scrollY > 8 : window.scrollY > 24;
108      if (wasSticky !== sticky) {
109        if (sticky) {
110          // CLS: on desktop the compact bar is position:fixed, so the page needs a
111          // spacer exactly as tall as the expanded header it replaces. Measure it
112          // while the header is still in flow, before the class switches.
113          root.style.setProperty('--ent-header-h', header.offsetHeight + 'px');
114        }
115        // Hide the header's children for the one frame in which they re-flow
116        // into the other layout: Chrome ignores visibility:hidden boxes in the
117        // Layout Instability metric, so the row change stops counting as CLS.
118        header.classList.add('is-switching');
119        header.classList.toggle('is-sticky', sticky);
120        root.classList.toggle('ent-header-fixed', sticky);
121        requestAnimationFrame(() => {
122          requestAnimationFrame(() => header.classList.remove('is-switching'));
123        });
124        queueReflow(); // available nav width changes between states
125      }
126      ticking = false;
127    };
128    window.addEventListener('scroll', () => {
129      if (!ticking) {
130        window.requestAnimationFrame(syncSticky);
131        ticking = true;
132      }
133    }, { passive: true });
134    syncSticky();
135  }
136
137  /* =========================================
138     Header info widget (cities / weather / FX)
139     ========================================= */
140  if (widget) {
141    const slides = [...widget.querySelectorAll('[data-ent-city-slide]')];
142    const dots = [...widget.querySelectorAll('[data-ent-city-dot]')];
143    const city = widget.querySelector('.ent-city');
144    let active = 0;
145    let rotation;
146
147    // Current site language: GTranslate cookie ("/fr/en") → <html lang> → fr.
148    // JS-inserted text is not translated by GTranslate, so we localize it ourselves.
149    const siteLang = (() => {
150      const m = document.cookie.match(/(?:^|;\s*)googtrans=\/[a-z-]+\/([a-z-]+)/i);
151      const lang = (m && m[1]) || document.documentElement.lang || 'fr';
152      return lang.toLowerCase().split('-')[0];
153    })();
154
155    const updateClocks = () => {
156      const now = new Date();
157      slides.forEach((slide) => {
158        const timezone = slide.dataset.timezone || 'UTC';
159        const time = slide.querySelector('[data-ent-city-time]');
160        const date = slide.querySelector('[data-ent-city-date]');
161        try {
162          time.textContent = new Intl.DateTimeFormat(siteLang, {
163            timeZone: timezone, hour: '2-digit', minute: '2-digit', hour12: false,
164          }).format(now);
165          date.textContent = new Intl.DateTimeFormat(siteLang, {
166            timeZone: timezone, day: '2-digit', month: 'long',
167          }).format(now).toUpperCase();
168        } catch (_) {
169          time.textContent = now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
170        }
171      });
172    };
173
174    const show = (next) => {
175      if (!slides.length) return;
176      active = (next + slides.length) % slides.length;
177      slides.forEach((slide, index) => {
178        const selected = index === active;
179        slide.hidden = !selected;
180        slide.classList.toggle('is-active', selected);
181      });
182      dots.forEach((dot, index) => {
183        dot.classList.toggle('is-active', index === active);
184        dot.setAttribute('aria-current', index === active ? 'true' : 'false');
185      });
186    };
187
188    const start = () => {
189      window.clearInterval(rotation);
190      if (slides.length > 1 && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
191        rotation = window.setInterval(() => show(active + 1), 10000);
192      }
193    };
194
195    widget.querySelector('[data-ent-city-prev]')?.addEventListener('click', () => { show(active - 1); start(); });
196    widget.querySelector('[data-ent-city-next]')?.addEventListener('click', () => { show(active + 1); start(); });
197    dots.forEach((dot, index) => dot.addEventListener('click', () => { show(index); start(); }));
198    city?.addEventListener('mouseenter', () => window.clearInterval(rotation));
199    city?.addEventListener('mouseleave', start);
200    city?.addEventListener('focusin', () => window.clearInterval(rotation));
201    city?.addEventListener('focusout', start);
202
203    const fx = widget.querySelector('[data-ent-fx]');
204
205    // FX auto-rotation (mobile shows one pair at a time; CSS decides visibility)
206    const initFxRotation = () => {
207      if (!fx) return;
208      const fxRates = [...fx.querySelectorAll('.ent-fx__rate')];
209      if (!fxRates.length) return;
210      fx.querySelector('.ent-fx__rates')?.classList.add('is-js');
211      fxRates.forEach((r) => r.classList.remove('is-active'));
212      let fxActive = 0;
213      fxRates[0].classList.add('is-active');
214      window.clearInterval(window.__entFxRot);
215      if (fxRates.length > 1 && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
216        window.__entFxRot = window.setInterval(() => {
217          fxRates[fxActive].classList.remove('is-active');
218          fxActive = (fxActive + 1) % fxRates.length;
219          fxRates[fxActive].classList.add('is-active');
220        }, 5000);
221      }
222    };
223
224    // Region-localised FX: when the visitor switched language, quote the region's
225    // own currency instead of the default euro pairs (e.g. UA → грн/USD).
226    // Values are "local currency per 1 foreign unit" so numbers stay readable.
227    const FX_REGION = {
228      uk: { base: 'UAH', quotes: ['USD', 'EUR', 'GBP', 'PLN'] },
229      ru: { base: 'RUB', quotes: ['USD', 'EUR', 'GBP', 'CNY'] },
230      ja: { base: 'JPY', quotes: ['USD', 'EUR', 'GBP', 'CNY'] },
231      ar: { base: 'AED', quotes: ['USD', 'EUR', 'GBP', 'SAR'] },
232      tr: { base: 'TRY', quotes: ['USD', 'EUR', 'GBP', 'RUB'] },
233      pl: { base: 'PLN', quotes: ['USD', 'EUR', 'GBP', 'CZK'] },
234    };
235
236    // Daily baseline in localStorage → a real day-over-day % (API updates ~1×/day).
237    const fxChange = (pair, val) => {
238      try {
239        const today = new Date().toISOString().slice(0, 10);
240        const key = 'entFxBase:' + pair;
241        const rec = JSON.parse(localStorage.getItem(key) || 'null');
242        if (!rec || !Number.isFinite(rec.val)) {
243          localStorage.setItem(key, JSON.stringify({ date: today, val }));
244          return 0;
245        }
246        const pct = rec.val ? ((val - rec.val) / rec.val) * 100 : 0;
247        if (rec.date !== today) localStorage.setItem(key, JSON.stringify({ date: today, val }));
248        return pct;
249      } catch (_) { return 0; }
250    };
251
252    const applyRegionFx = async () => {
253      const region = FX_REGION[siteLang];
254      const wrap = fx?.querySelector('.ent-fx__rates');
255      if (!region || !wrap) { initFxRotation(); return; }
256      try {
257        const ckey = 'entFxRegion:' + region.base;
258        let rates = null;
259        try {
260          const c = JSON.parse(sessionStorage.getItem(ckey) || 'null');
261          if (c && (Date.now() - c.t) < 6 * 3600 * 1000) rates = c.rates;
262        } catch (_) {}
263        if (!rates) {
264          const res = await fetch('https://open.er-api.com/v6/latest/' + region.base);
265          const j = res.ok ? await res.json() : null;
266          if (!j || j.result !== 'success' || !j.rates) { initFxRotation(); return; }
267          rates = j.rates;
268          try { sessionStorage.setItem(ckey, JSON.stringify({ t: Date.now(), rates })); } catch (_) {}
269        }
270        const html = region.quotes.map((q) => {
271          const r = rates[q];
272          if (!r) return '';
273          const val = 1 / r;                       // local currency per 1 foreign unit
274          const dec = val >= 100 ? 1 : (val >= 1 ? 2 : 4);
275          const pair = region.base + '/' + q;
276          const pct = fxChange(pair, val);
277          const dir = Math.abs(pct) < 0.005 ? 'flat' : (pct < 0 ? 'down' : 'up');
278          const arrow = dir === 'down' ? '↓' : (dir === 'up' ? '↑' : '');
279          return '<article class="ent-fx__rate"><span>' + pair + '</span>' +
280            '<strong>' + val.toFixed(dec) + (arrow ? ' <i class="is-' + dir + '">' + arrow + '</i>' : '') + '</strong>' +
281            '<small class="is-' + dir + '">' + (pct > 0 ? '+' : '') + pct.toFixed(2) + '%</small></article>';
282        }).join('');
283        if (html) wrap.innerHTML = html;
284      } catch (_) {}
285      initFxRotation();
286    };
287    applyRegionFx();
288
289    updateClocks();
290    window.setInterval(updateClocks, 30000);
291    show(0);
292    start();
293
294    /* -----------------------------------------
295       Local city slide — time & weather for the
296       visitor's own location.
297       No permission prompt: city is derived from
298       the browser IANA timezone (city-level only),
299       weather comes from the free Open-Meteo API.
300       ----------------------------------------- */
301    // Correct romanization for cities whose old exonyms linger in data sources
302    const LATIN_CITY_FIXES = { kiev: 'Kyiv', kiew: 'Kyjiw' };
303    const fixLatinCity = (name) => LATIN_CITY_FIXES[String(name).toLowerCase()] || name;
304
305    // Languages written in non-Latin scripts → take the city name from
306    // Wikipedia langlinks; Latin-script languages use the romanized name.
307    const NON_LATIN_LANGS = ['uk', 'ru', 'ar', 'ja', 'zh', 'ko', 'he', 'el', 'bg', 'sr'];
308
309    const YOUR_CITY_LABEL = {
310      fr: 'Votre ville', en: 'Your city', de: 'Ihre Stadt', es: 'Tu ciudad',
311      it: 'La tua città', uk: 'Ваше місто', ru: 'Ваш город',
312      ar: 'مدينتك', ja: 'あなたの街', zh: '您的城市',
313    };
314
315    // Localized weather condition from a WMO weather_code (so the visitor's
316    // local city matches the server-rendered world cities, which show a label).
317    const ent_wx_condition = (lang, code) => {
318      const cat =
319        code === 0 ? 'clear'
320        : [1, 2, 3].includes(code) ? 'cloud'
321        : [45, 48].includes(code) ? 'fog'
322        : ((code >= 51 && code <= 67) || (code >= 80 && code <= 82)) ? 'rain'
323        : ((code >= 71 && code <= 77) || [85, 86].includes(code)) ? 'snow'
324        : (code >= 95) ? 'storm'
325        : 'cloud';
326      const T = {
327        fr: { clear: 'Soleil', cloud: 'Nuageux', fog: 'Brouillard', rain: 'Pluie', snow: 'Neige', storm: 'Orage' },
328        en: { clear: 'Sunny', cloud: 'Cloudy', fog: 'Fog', rain: 'Rain', snow: 'Snow', storm: 'Storm' },
329        de: { clear: 'Sonnig', cloud: 'Bewölkt', fog: 'Nebel', rain: 'Regen', snow: 'Schnee', storm: 'Gewitter' },
330        it: { clear: 'Soleggiato', cloud: 'Nuvoloso', fog: 'Nebbia', rain: 'Pioggia', snow: 'Neve', storm: 'Temporale' },
331        es: { clear: 'Soleado', cloud: 'Nublado', fog: 'Niebla', rain: 'Lluvia', snow: 'Nieve', storm: 'Tormenta' },
332        ru: { clear: 'Ясно', cloud: 'Облачно', fog: 'Туман', rain: 'Дождь', snow: 'Снег', storm: 'Гроза' },
333        uk: { clear: 'Сонячно', cloud: 'Хмарно', fog: 'Туман', rain: 'Дощ', snow: 'Сніг', storm: 'Гроза' },
334        ar: { clear: 'مشمس', cloud: 'غائم', fog: 'ضباب', rain: 'ممطر', snow: 'ثلج', storm: 'عاصفة' },
335        ja: { clear: '晴れ', cloud: '曇り', fog: '霧', rain: '雨', snow: '雪', storm: '雷雨' },
336        zh: { clear: '晴', cloud: '多云', fog: '雾', rain: '雨', snow: '雪', storm: '雷雨' },
337      };
338      return (T[lang] || T.fr)[cat] || '';
339    };
340
341    // Localized "Feels like X° · High Y° / Low Z°" — omits missing values cleanly.
342    const ent_wx_details_line = (lang, feels, tmax, tmin) => {
343      const T = {
344        fr: { feel: 'Ressenti', max: 'Max', min: 'Min' },
345        en: { feel: 'Feels like', max: 'High', min: 'Low' },
346        de: { feel: 'Gefühlt', max: 'Max', min: 'Min' },
347        es: { feel: 'Sensación', max: 'Máx', min: 'Mín' },
348        it: { feel: 'Percepiti', max: 'Max', min: 'Min' },
349        uk: { feel: 'Відчувається як', max: 'Макс.', min: 'Мін.' },
350        ru: { feel: 'Ощущается как', max: 'Макс.', min: 'Мин.' },
351        ar: { feel: 'محسوس', max: 'العظمى', min: 'الصغرى' },
352        ja: { feel: '体感', max: '最高', min: '最低' },
353        zh: { feel: '体感', max: '最高', min: '最低' },
354      };
355      const t = T[lang] || T.fr;
356      const parts = [];
357      if (Number.isFinite(feels)) parts.push(t.feel + ' ' + feels + '°');
358      const mm = [];
359      if (Number.isFinite(tmax)) mm.push(t.max + ' ' + tmax + '°');
360      if (Number.isFinite(tmin)) mm.push(t.min + ' ' + tmin + '°');
361      if (mm.length) parts.push(mm.join(' / '));
362      return parts.join(' · ');
363    };
364
365    const addLocalCitySlide = async () => {
366      const tz = (Intl.DateTimeFormat().resolvedOptions().timeZone || '').trim();
367      if (!tz || !tz.includes('/')) return;
368      // already covered by a configured city?
369      if (slides.some((slide) => (slide.dataset.timezone || '') === tz)) return;
370
371      const cityRaw = fixLatinCity(tz.split('/').pop().replace(/_/g, ' '));
372      if (!cityRaw) return;
373
374      const cacheKey = 'entLocalWx4:' + siteLang + ':' + tz;
375      let data = null;
376      try {
377        const cached = JSON.parse(sessionStorage.getItem(cacheKey) || 'null');
378        if (cached && Date.now() - cached.t < 30 * 60 * 1000) data = cached;
379      } catch (_) {}
380
381      if (!data) {
382        const geoRes = await fetch(
383          'https://geocoding-api.open-meteo.com/v1/search?count=1&language=fr&name=' + encodeURIComponent(cityRaw)
384        );
385        if (!geoRes.ok) return;
386        const geo = await geoRes.json();
387        const hit = geo && geo.results && geo.results[0];
388        if (!hit) return;
389
390        const wxRes = await fetch(
391          'https://api.open-meteo.com/v1/forecast?current=temperature_2m,apparent_temperature,weather_code&daily=temperature_2m_max,temperature_2m_min&forecast_days=1&timezone=auto&latitude=' + hit.latitude + '&longitude=' + hit.longitude
392        );
393        if (!wxRes.ok) return;
394        const wx = await wxRes.json();
395        const cur = (wx && wx.current) || {};
396        const daily = (wx && wx.daily) || {};
397        const temp = Math.round(cur.temperature_2m);
398        if (!Number.isFinite(temp)) return;
399        const feels = Number.isFinite(cur.apparent_temperature) ? Math.round(cur.apparent_temperature) : null;
400        const tmax = daily.temperature_2m_max ? Math.round(daily.temperature_2m_max[0]) : null;
401        const tmin = daily.temperature_2m_min ? Math.round(daily.temperature_2m_min[0]) : null;
402        const cond = Number.isFinite(cur.weather_code) ? ent_wx_condition(siteLang, cur.weather_code) : '';
403
404        // Display name: romanized for Latin-script languages, localized via
405        // Wikipedia langlinks for non-Latin ones (e.g. Київ / キーウ / 基輔).
406        let displayName = fixLatinCity(hit.name || cityRaw);
407        if (NON_LATIN_LANGS.includes(siteLang)) {
408          try {
409            const llRes = await fetch(
410              'https://en.wikipedia.org/w/api.php?action=query&prop=langlinks&redirects=1&format=json&origin=*&lllang=' + siteLang + '&titles=' + encodeURIComponent(cityRaw)
411            );
412            if (llRes.ok) {
413              const ll = await llRes.json();
414              const page = ll && ll.query && ll.query.pages && Object.values(ll.query.pages)[0];
415              const localized = page && page.langlinks && page.langlinks[0] && page.langlinks[0]['*'];
416              if (localized) displayName = localized;
417            }
418          } catch (_) {}
419        }
420
421        data = { t: Date.now(), name: displayName, temp, feels, tmax, tmin, cond, photo: '' };
422        try { sessionStorage.setItem(cacheKey, JSON.stringify(data)); } catch (_) {}
423      }
424
425      const slidesWrap = widget.querySelector('.ent-city__slides');
426      if (!slidesWrap) return;
427
428      const sunSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"></path></svg>';
429
430      const slide = document.createElement('article');
431      slide.className = 'ent-city__slide';
432      slide.setAttribute('data-ent-city-slide', '');
433      slide.dataset.timezone = tz;
434      slide.hidden = true;
435      slide.innerHTML =
436        '<span class="ent-city__mark ent-city__mark--local" aria-hidden="true"><span></span></span>' +
437        '<span class="ent-city__place">
437' +
438          '<strong class="ent-city__name"></strong>' +
439          '<time class="ent-city__time" data-ent-city-time>--:--</time>' +
440          '<small class="ent-city__date" data-ent-city-date></small>' +
441        '</span>' +
442        '<span class="ent-city__weather">' +
443          '<span class="ent-city__wx">' +
444            '<span class="ent-weather-icon" aria-hidden="true">' + sunSvg + '</span>' +
445            '<span class="ent-city__wx-values">' +
446              '<strong class="ent-city__temp"></strong>' +
447              '<small class="ent-city__cond" hidden></small>' +
448            '</span>' +
449          '</span>' +
450        '</span>';
451
452      const mark = slide.querySelector('.ent-city__mark');
453      mark.querySelector('span').textContent = data.name.charAt(0).toUpperCase();
454
455      const applyPhoto = (photo) => {
456        if (!photo) return;
457        mark.classList.add('ent-city__mark--photo');
458        mark.style.backgroundImage = 'url("' + photo + '")';
459      };
460
461      // City photo — Wikipedia (Wikimedia Commons) page thumbnail.
462      // fr → en, REST summary first, PageImages API as a fallback; SVG
463      // flags/coats of arms are skipped. Runs in the background so a failed
464      // photo lookup never blocks the slide, and retries on the next page
465      // load when the cached entry has no photo yet.
466      const fetchCityPhoto = async () => {
467        const looksLikePhoto = (url) => !!url && !/\.svg|flag_of|coat_of_arms|blason|armoiries|escudo|wappen/i.test(url);
468        // Latin (romanized) title first — fr/en Wikipedia lookups need it even
469        // when the display name is non-Latin (Київ, キーウ, …)
470        const titles = [...new Set([cityRaw, data.name])];
471
472        for (const lang of ['fr', 'en']) {
473          for (const title of titles) {
474            try {
475              const res = await fetch(
476                'https://' + lang + '.wikipedia.org/api/rest_v1/page/summary/' + encodeURIComponent(title.replace(/ /g, '_'))
477              );
478              if (!res.ok) continue;
479              const j = await res.json();
480              const src = j && j.thumbnail && j.thumbnail.source;
481              if (looksLikePhoto(src)) return src;
482            } catch (_) {}
483          }
484        }
485
486        for (const lang of ['fr', 'en']) {
487          try {
488            const res = await fetch(
489              'https://' + lang + '.wikipedia.org/w/api.php?action=query&prop=pageimages&piprop=thumbnail&pithumbsize=330&redirects=1&format=json&origin=*&titles=' + encodeURIComponent(titles[0])
490            );
491            if (!res.ok) continue;
492            const j = await res.json();
493            const pages = j && j.query && j.query.pages;
494            const first = pages && Object.values(pages)[0];
495            const src = first && first.thumbnail && first.thumbnail.source;
496            if (looksLikePhoto(src)) return src;
497          } catch (_) {}
498        }
499
500        return '';
501      };
502
503      if (data.photo) {
504        applyPhoto(data.photo);
505      } else {
506        fetchCityPhoto().then((photo) => {
507          if (!photo) return;
508          applyPhoto(photo);
509          data.photo = photo;
510          try { sessionStorage.setItem(cacheKey, JSON.stringify(data)); } catch (_) {}
511        }).catch(() => {});
512      }
513
514      slide.querySelector('.ent-city__name').textContent = data.name;
515      slide.querySelector('.ent-city__temp').textContent = data.temp + '°';
516
517      const condEl = slide.querySelector('.ent-city__cond');
518      if (condEl && data.cond) {
519        condEl.textContent = data.cond;
520        condEl.hidden = false;
521      }
522
523      slidesWrap.appendChild(slide);
524      slides.push(slide);
525
526      const dotsWrap = widget.querySelector('.ent-city__dots');
527      if (dotsWrap) {
528        const dot = document.createElement('button');
529        dot.type = 'button';
530        dot.setAttribute('aria-label', data.name);
531        const dotIndex = dots.length;
532        dot.addEventListener('click', () => { show(dotIndex); start(); });
533        dotsWrap.appendChild(dot);
534        dots.push(dot);
535      }
536
537      updateClocks();
538      show(slides.length - 1); // lead with the visitor's city
539      start();
540    };
541
542    addLocalCitySlide().catch(() => {});
543  }
544})();

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.