PageSourceSearch

https://altobonito-1.vercel.app/js/ui/f-tourism.js?v=15

js altobonito-1.vercel.app collected 2026-10-03 06:15:42 UTC 34,962 bytes, 1,068 lines download raw bytes

1/**
2 * f-tourism.js — Jarvis Turismo Austral
3 * Catálogo curado + validación real (Wikipedia foto, YouTube oEmbed).
4 * Sin media verificada → no se muestra ese bloque.
5 */
6'use strict';
7
8(function () {
9  const CATALOG_URL = 'data/tourism-catalog.json?v=10';
10  const EARTH_R_M = 6371000;
11  /** false = solo video si hay youtubeCandidates curado + oEmbed OK (sin búsqueda proxy) */
12  const ENABLE_YT_LIVE_SEARCH = false;
13  const DEFAULT_BANDS = [
14    { id: 'muy_cerca', label: 'Muy cerca', emoji: '🟢', maxKm: 40 },
15    { id: 'paseo', label: 'A un paseo', emoji: '🟡', maxKm: 100 },
16    { id: 'dia', label: 'Día completo', emoji: '🟠', maxKm: 180 },
17    { id: 'lejos', label: 'Más lejos', emoji: '🔵', maxKm: 320 }
18  ];
19
20  let _catalog = null;
21  let _loadPromise = null;
22  let _pendingOffer = null; // { category, poiId, resolved }
23  let _pendingMenu = null; // { category, items: [...] }
24  let _openPoiId = null;
25  let _mediaCache = new Map(); // poiId → { imageUrl, youtubeId, wikiExtract, title }
26
27  function _haversineM(lat1, lng1, lat2, lng2) {
28    const toRad = (d) => (d * Math.PI) / 180;
29    const dLat = toRad(lat2 - lat1);
30    const dLng = toRad(lng2 - lng1);
31    const a =
32      Math.sin(dLat / 2) ** 2 +
33      Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
34    return 2 * EARTH_R_M * Math.asin(Math.min(1, Math.sqrt(a)));
35  }
36
37  function _origin() {
38    const o = window.FerrariGeo && window.FerrariGeo.droneOrigin;
39    if (o && o.lat != null && o.lng != null) return { lat: +o.lat, lng: +o.lng };
40    // Fallback zona Puerto Varas / Ensenada si aún no hay origen
41    return { lat: -41.32, lng: -72.98 };
42  }
43
44  function _formatDist(m) {
45    if (window.FerrariGeo && typeof window.FerrariGeo.formatDistance === 'function') {
46      return window.FerrariGeo.formatDistance(m);
47    }
48    if (m < 1000) return Math.round(m) + ' m';
49    return (m / 1000).toFixed(m < 20000 ? 1 : 0) + ' km';
50  }
51
52  function _formatEta(m) {
53    if (window.FerrariGeo && typeof window.FerrariGeo.formatEtaMinutes === 'function') {
54      return window.FerrariGeo.formatEtaMinutes(m);
55    }
56    const min = Math.max(1, Math.round(m / 700)); // ~42 km/h rural
57    if (min < 60) return '~' + min + ' min';
58    const h = Math.floor(min / 60);
59    const r = min % 60;
60    return '~' + h + ' h' + (r ? ' ' + r + ' min' : '');
61  }
62
63  async function loadCatalog(force) {
64    if (_catalog && !force) return _catalog;
65    if (_loadPromise && !force) return _loadPromise;
66    _loadPromise = fetch(CATALOG_URL, { cache: 'no-store' })
67      .then((r) => {
68        if (!r.ok) throw new Error('catalog HTTP ' + r.status);
69        return r.json();
70      })
71      .then((j) => {
72        _catalog = j;
73        try {
74          if (typeof window.__kpkRefreshTourismChips === 'function') {
75            window.__kpkRefreshTourismChips();
76          }
77        } catch (e) {}
78        return j;
79      })
80      .catch((e) => {
81        console.warn('[Tourism] No se pudo cargar catálogo:', e.message);
82        _catalog = { categories: [], pois: [] };
83        return _catalog;
84      });
85    return _loadPromise;
86  }
87
88  function _bands() {
89    return (_catalog && Array.isArray(_catalog.bands) && _catalog.bands.length)
90      ? _catalog.bands
91      : DEFAULT_BANDS;
92  }
93
94  function _maxRadiusM() {
95    const km = (_catalog && _catalog.maxRadiusKm) || 320;
96    return km * 1000;
97  }
98
99  function _bandForKm(km) {
100    const bands = _bands();
101    for (const b of bands) {
102      if (km <= b.maxKm) return b;
103    }
104    return bands[bands.length - 1] || { id: 'lejos', label: 'Más lejos', emoji: '🔵', maxKm: 999 };
105  }
106
107  function _enrichPoi(p) {
108    const origin = _origin();
109    const distM = _haversineM(origin.lat, origin.lng, p.lat, p.lng);
110    const km = distM / 1000;
111    const band = _bandForKm(km);
112    return Object.assign({}, p, {
113      distM,
114      distKm: Math.round(km * 10) / 10,
115      distLabel: _formatDist(distM),
116      etaLabel: _formatEta(distM),
117      bandId: band.id,
118      bandLabel: band.label,
119      bandEmoji: band.emoji || ''
120    });
121  }
122
123  function listByCategory(category) {
124    if (!_catalog) return [];
125    const cat = String(category || '').toLowerCase();
126    const maxM = _maxRadiusM();
127    return (_catalog.pois || [])
128      .filter((p) => !cat || p.category === cat)
129      .map(_enrichPoi)
130      .filter((p) => p.distM <= maxM)
131      .sort((a, b) => a.distM - b.distM);
132  }
133
134  function getPoi(id) {
135    if (!_catalog) return null;
136    return (_catalog.pois || []).find((p) => p.id === id) || null;
137  }
138
139  async function _validateYoutube(id) {
140    if (!id || !/^[a-zA-Z0-9_-]{6,20}$/.test(id)) return null;
141    try {
142      const url =
143        'https://www.youtube.com/oembed?url=' +
144        encodeURIComponent('https://www.youtube.com/watch?v=' + id) +
145        '&format=json';
146      const r = await fetch(url);
147      if (!r.ok) return null;
148      const j = await r.json();
149      if (!j || !j.title) return null;
150      return { id, title: j.title, thumb: j.thumbnail_url || null };
151    } catch (e) {
152      return null;
153    }
154  }
155
156  async function _fetchWiki(wiki) {
157    if (!wiki || !wiki.title) return null;
158    const lang = wiki.lang || 'es';
159    const url =
160      'https://' +
161      lang +
162      '.wikipedia.org/api/rest_v1/page/summary/' +
163      encodeURIComponent(wiki.title);
164    try {
165      const r = await fetch(url);
166      if (!r.ok) return null;
167      const j = await r.json();
168      if (!j || j.type === 'disambiguation' || j.type === 'https://mediawiki.org/wiki/HyperSwitch/errors/not_found') {
169        return null;
170      }
171      const imageUrl = (j.thumbnail && j.thumbnail.source) || (j.originalimage && j.originalimage.source) || null;
172      let lat = null;
173      let lng = null;
174      if (j.coordinates) {
175        lat = j.coordinates.lat;
176        lng = j.coordinates.lon;
177      }
178      return {
179        title: j.title || null,
180        extract: j.extract || j.description || null,
181        imageUrl,
182        lat,
183        lng,
184        pageUrl: j.content_urls && j.content_urls.desktop ? j.content_urls.desktop.page : null
185      };
186    } catch (e) {
187      return null;
188    }
189  }
190
191  /** Busca en Wikimedia Commons (internet en el momento) una foto real del lugar */
192  async function _fetchCommonsImage(query) {
193    if (!query) return null;
194    try {
195      const url =
196        'https://commons.wikimedia.org/w/api.php?action=query&generator=search&gsrnamespace=6&gsrsearch=' +
197        encodeURIComponent(query) +
198        '&gsrlimit=6&prop=imageinfo&iiprop=url|mime|size&iiurlwidth=1280&format=json&origin=*';
199      const r = await fetch(url);
200      if (!r.ok) return null;
201      const j = await r.json();
202      const pages = j.query && j.query.pages ? Object.values(j.query.pages) : [];
203      for (const p of pages) {
204        const ii = p.imageinfo && p.imageinfo[0];
205        if (!ii) continue;
206        if (ii.mime && !String(ii.mime).startsWith('image/')) continue;
207        const candidate = ii.thumburl || ii.url;
208        if (!candidate) continue;
209        const ok = await _probeImage(candidate);
210        if (ok) return candidate;
211      }
212    } catch (e) {}
213    return null;
214  }
215
216  async function _probeImage(url) {
217    if (!url) return false;
218    return new Promise((resolve) => {
219      const img = new Image();
220      const t = setTimeout(() => {
221        img.src = '';
222        resolve(false);
223      }, 6000);
224      img.onload = () => {
225        clearTimeout(t);
226        resolve(img.naturalWidth > 40 && img.naturalHeight > 40);
227      };
228      img.onerror = () => {
229        clearTimeout(t);
230        resolve(false);
231      };
232      img.referrerPolicy = 'no-referrer';
233      img.src = url;
234    });
235  }
236
237  function _titlesRelated(poiTitle, wikiTitle) {
238    if (!poiTitle || !wikiTitle) return false;
239    const norm = (s) =>
240      String(s)
241        .toLowerCase()
242        .normalize('NFD')
243        .replace(/[\u0300-\u036f]/g, '')
244        .replace(/[^a-z0-9\s]/g, ' ')
245        .replace(/\s+/g, ' ')
246        .trim();
247    const a = norm(poiTitle);
248    const b = norm(wikiTitle);
249    if (a.includes(b) || b.includes(a)) return true;
250    const wordsA = a.split(' ').filter((w) => w.length > 3);
251    const hit = wordsA.filter((w) => b.includes(w)).length;
252    return hit >= 1 && (hit / Math.max(1, wordsA.length) >= 0.4 || b.split(' ').some((w) => w.length > 4 && a.includes(w)));
253  }
254
255  /** Base del puente Node (mismo que TTS). Vacío si no hay. */
256  function _mediaProxyBase() {
257    try {
258      const cfg = window.KPK_CONFIG || {};
259      if (cfg.ttsProxyUrl) return String(cfg.ttsProxyUrl).trim().replace(/\/$/, '');
260      if (window.FerrariBrandDock && typeof window.FerrariBrandDock.getBrand === 'function') {
261        const b = window.FerrariBrandDock.getBrand();
262        if (b && b.ttsProxyUrl) return String(b.ttsProxyUrl).trim().replace(/\/$/, '');
263      }
264    } catch (e) {}
265    // Localhost solo en http (GitHub HTTPS bloquea)
266    if (location.protocol === 'http:' || location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
267      return 'http://127.0.0.1:8787';
268    }
269    return '';
270  }
271
272  function _youtubeQueryForPoi(poi) {
273    if (poi.youtubeSearch) return String(poi.youtubeSearch).trim();
274    const bits = [poi.title, 'Chile'];
275    if (poi.category === 'termas') bits.push('termas');
276    if (poi.category === 'rafting') bits.push('rafting');
277    if (poi.category === 'trekking') bits.push('parque');
278    return bits.filter(Boolean).join(' ');
279  }
280
281  /**
282   * Busca el video correcto en YouTube vía proxy Node (scraping resultados YT + oEmbed).
283   * El navegador NO puede scrapear Google/YouTube directo (CORS).
284   */
285  async function _searchYoutubeLive(poi) {
286    const base = _mediaProxyBase();
287    if (!base) return null;
288    const q = _youtubeQueryForPoi(poi);
289    if (!q) return null;
290    try {
291      const url =
292        base +
293        '/yt-search?q=' +
294        encodeURIComponent(q) +
295        '&limit=5&minScore=30';
296      const ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
297      const t = ctrl ? setTimeout(() => ctrl.abort(), 12000) : null;
298      const r = await fetch(url, {
299        method: 'GET',
300        mode: 'cors',
301        cache: 'no-store',
302        signal: ctrl ? ctrl.signal : undefined
303      });
304      if (t) clearTimeout(t);
305      if (!r.ok) return null;
306      const j = await r.json();
307      const results = (j && j.results) || [];
308      for (const item of results) {
309        if (!item || !item.id) continue;
310        // Re-validar oEmbed desde el cliente (doble filtro)
311        const ok = await _validateYoutube(item.id);
312        if (ok) {
313          return {
314            id: ok.id,
315            title: ok.title,
316            thumb: ok.thumb,
317            score: item.score,
318            source: 'yt_search'
319          };
320        }
321      }
322    } catch (e) {
323      console.warn('[Tourism] yt-search falló:', e.message || e);
324    }
325    return null;
326  }
327
328  async function resolveMedia(poi) {
329    if (!poi) return null;
330    if (_mediaCache.has(poi.id)) {
331      const cached = _mediaCache.get(poi.id);
332      // Reintento de búsqueda YT solo si está habilitada
333      if (
334        ENABLE_YT_LIVE_SEARCH &&
335        cached &&
336        cached.ok &&
337        !cached.youtube &&
338        !cached._ytTriedLive
339      ) {
340        const live = await _searchYoutubeLive(poi);
341        cached._ytTriedLive = true;
342        if (live) {
343          cached.youtube = live;
344          _mediaCache.set(poi.id, cached);
345        }
346      }
347      return _mediaCache.get(poi.id);
348    }
349
350    // 1) Fotos curadas en catálogo (Commons/URL) — se validan al momento
351    let imageUrl = null;
352    let imageSource = null;
353    const curated = Array.isArray(poi.imageCandidates) ? poi.imageCandidates : [];
354    for (const url of curated) {
355      if (await _probeImage(url)) {
356        imageUrl = url;
357        imageSource = 'catalog';
358        break;
359      }
360    }
361
362    // 2) Wikipedia (internet al momento)
363    const wiki = await _fetchWiki(poi.wiki);
364    if (!imageUrl && wiki && wiki.imageUrl) {
365      if (await _probeImage(wiki.imageUrl)) {
366        imageUrl = wiki.imageUrl;
367        imageSource = 'wikipedia';
368      }
369    }
370
371    // 3) Búsqueda Commons al momento (si aún no hay foto)
372    if (!imageUrl) {
373      const q = poi.commonsSearch || poi.title;
374      const commonsUrl = await _fetchCommonsImage(q);
375      if (commonsUrl) {
376        imageUrl = commonsUrl;
377        imageSource = 'commons';
378      }
379    }
380
381    // 4) YouTube: SOLO IDs curados + oEmbed. Sin ID → sin video (búsqueda live off por defecto).
382    let youtube = null;
383    const candidates = Array.isArray(poi.youtubeCandidates) ? poi.youtubeCandidates : [];
384    for (const id of candidates) {
385      if (!id) continue;
386      youtube = await _validateYoutube(id);
387      if (youtube) {
388        youtube.source = 'catalog';
389        break;
390      }
391    }
392    let ytTriedLive = false;
393    if (!youtube && ENABLE_YT_LIVE_SEARCH) {
394      ytTriedLive = true;
395      youtube = await _searchYoutubeLive(poi);
396    }
397
398    // Texto descriptivo
399    const wikiRelated = wiki && _titlesRelated(poi.title, wiki.title);
400    const wikiExtract =
401      wikiRelated && wiki.extract ? wiki.extract.slice(0, 280) : null;
402
403    // Coordenadas: el catálogo es la fuente de verdad (coordsVerified = revisadas a mano).
404    // Wikipedia solo se usa si el POI no trae lat/lng.
405    let outLat = poi.lat;
406    let outLng = poi.lng;
407    if ((outLat == null || outLng == null) && wiki && wiki.lat != null && wiki.lng != null) {
408      outLat = wiki.lat;
409      outLng = wiki.lng;
410    } else if (
411      wiki &&
412      wiki.lat != null &&
413      wiki.lng != null &&
414      outLat != null &&
415      outLng != null &&
416      !poi.coordsVerified
417    ) {
418      // Aviso silencioso si Wikipedia discrepa mucho (>8 km) — no reemplaza catálogo
419      const driftM = _haversineM(outLat, outLng, wiki.lat, wiki.lng);
420      if (driftM > 8000) {
421        console.warn(
422          '[Tourism] Coords catálogo vs Wikipedia difieren',
423          Math.round(driftM / 1000) + ' km ·',
424          poi.id,
425          'usando catálogo'
426        );
427      }
428    }
429
430    // Suficiente: coords + descripción (foto/video opcionales)
431    const hasPlace = outLat != null && outLng != null;
432    const hasDesc = !!(poi.blurb || wikiExtract);
433    if (!hasPlace || (!imageUrl && !youtube && !hasDesc)) {
434      const empty = {
435        ok: false,
436        imageUrl: null,
437        youtube: null,
438        wikiExtract: null,
439        _ytTriedLive: ytTriedLive
440      };
441      _mediaCache.set(poi.id, empty);
442      return empty;
443    }
444
445    const resolved = {
446      ok: true,
447      imageUrl,
448      imageSource,
449      youtube,
450      wikiExtract,
451      wikiTitle: wiki && wiki.title ? wiki.title : poi.title,
452      wikiPageUrl: wiki && wiki.pageUrl ? wiki.pageUrl : null,
453      lat: outLat,
454      lng: outLng,
455      _ytTriedLive: ytTriedLive
456    };
457    _mediaCache.set(poi.id, resolved);
458    return resolved;
459  }
460
461  function _ensureWidget() {
462    let el = document.getElementById('kpk-tourism-widget');
463    if (el) return el;
464    el = document.createElement('div');
465    el.id = 'kpk-tourism-widget';
466    el.className = 'kpk-tourism-widget';
467    el.innerHTML = `
468      <div class="kpk-tw-header">
469        <div class="kpk-tw-titles">
470          <span class="kpk-tw-eyebrow" id="kpk-tw-eyebrow">Jarvis Turismo</span>
471          <span class="kpk-tw-title" id="kpk-tw-title">—</span>
472        </div>
473        <button type="button" class="kpk-tw-close" id="kpk-tw-close" title="Cerrar">&times;</button>
474      </div>
475      <div class="kpk-tw-media" id="kpk-tw-media"></div>
476      <div class="kpk-tw-body">
477        <p class="kpk-tw-blurb" id="kpk-tw-blurb"></p>
478        <p class="kpk-tw-meta" id="kpk-tw-meta"></p>
479      </div>
480      <div class="kpk-tw-footer" id="kpk-tw-footer"></div>
481    `;
482    document.body.appendChild(el);
483    el.querySelector('#kpk-tw-close').addEventListener('click', closeWidget);
484    if (window.FerrariDrag) {
485      window.FerrariDrag.attach(el, { handle: '.kpk-tw-header' });
486    }
487    return el;
488  }
489
490  /** Muestra foto y/o video; si YouTube oEmbed OK, despliega el panel video automáticamente. */
491  function _renderTourismMedia(mediaBox, media, poi) {
492    mediaBox.innerHTML = '';
493    const hasYt = !!(media.youtube && media.youtube.id);
494    const hasImg = !!media.imageUrl;
495
496    if (!hasYt && !hasImg) {
497      mediaBox.innerHTML =
498        '<div class="kpk-tw-map-hint">Ruta desde el proyecto · usa el mapa o los botones de abajo</div>';
499      return;
500    }
501
502    if (hasYt && hasImg) {
503      const tabs = document.createElement('div');
504      tabs.className = 'kpk-tw-media-tabs';
505      tabs.innerHTML =
506        '<button type="button" class="kpk-tw-tab is-active" data-panel="video">▶ Video</button>' +
507        '<button type="button" class="kpk-tw-tab" data-panel="photo">Foto</button>';
508      mediaBox.appendChild(tabs);
509      tabs.querySelectorAll('.kpk-tw-tab').forEach((tab) => {
510        tab.addEventListener('click', () => {
511          const panel = tab.getAttribute('data-panel');
512          tabs.querySelectorAll('.kpk-tw-tab').forEach((t) => t.classList.toggle('is-active', t === tab));
513          mediaBox.querySelectorAll('.kpk-tw-panel').forEach((p) => {
514            p.classList.toggle('is-active', p.getAttribute('data-panel') === panel);
515          });
516        });
517      });
518    }
519
520    const stage = document.createElement('div');
521    stage.className = 'kpk-tw-media-stage';
522    mediaBox.appendChild(stage);
523
524    if (hasYt) {
525      const wrap = document.createElement('div');
526      wrap.className = 'kpk-tw-panel kpk-tw-video is-active';
527      wrap.setAttribute('data-panel', 'video');
528      const title = (media.youtube.title || poi.title || 'Video').replace(/"/g, '');
529      // Embed directo: oEmbed ya validó el ID. autoplay muted ayuda a “desplegar” sin gesto en varios browsers.
530      wrap.innerHTML =
531        '<iframe src="https://www.youtube-nocookie.com/embed/' +
532        encodeURIComponent(media.youtube.id) +
533        '?rel=0&modestbranding=1&playsinline=1&autoplay=1&mute=1" title="' +
534        title +
535        '" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture;
535 web-share" allowfullscreen loading="eager" referrerpolicy="strict-origin-when-cross-origin"></iframe>';
536      stage.appendChild(wrap);
537    }
538
539    if (hasImg) {
540      const photoPanel = document.createElement('div');
541      photoPanel.className = 'kpk-tw-panel' + (hasYt ? '' : ' is-active');
542      photoPanel.setAttribute('data-panel', 'photo');
543      const img = document.createElement('img');
544      img.className = 'kpk-tw-photo';
545      img.alt = poi.title;
546      img.referrerPolicy = 'no-referrer';
547      img.src = media.imageUrl;
548      photoPanel.appendChild(img);
549      stage.appendChild(photoPanel);
550    }
551  }
552
553  function _showTourismVideoPanel(el) {
554    if (!el) return;
555    const videoTab = el.querySelector('.kpk-tw-tab[data-panel="video"]');
556    if (videoTab) videoTab.click();
557    const video = el.querySelector('.kpk-tw-video');
558    if (video) video.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
559  }
560
561  function closeWidget() {
562    const el = document.getElementById('kpk-tourism-widget');
563    if (el) {
564      el.classList.remove('is-open');
565      const media = el.querySelector('#kpk-tw-media');
566      if (media) media.innerHTML = ''; // corta video
567    }
568    _openPoiId = null;
569    try {
570      document.body.classList.remove('kpk-tourism-open');
571    } catch (e) {}
572  }
573
574  function isOpen() {
575    const el = document.getElementById('kpk-tourism-widget');
576    return !!(el && el.classList.contains('is-open'));
577  }
578
579  /** Ajusta clases de layout (desktop vs móvil, convivencia con chat/mapa). */
580  function _syncTourismLayout() {
581    try {
582      const panel = document.getElementById('kpk-ai-panel');
583      if (panel && panel.classList.contains('is-open')) {
584        document.body.classList.add('kpk-ai-panel-open');
585      } else {
586        document.body.classList.remove('kpk-ai-panel-open');
587      }
588    } catch (e) {}
589  }
590
591  async function openWidget(poiIdOrOpts) {
592    await loadCatalog();
593    const opts = typeof poiIdOrOpts === 'string' ? { poiId: poiIdOrOpts } : poiIdOrOpts || {};
594    let poi = opts.poiId ? getPoi(opts.poiId) : null;
595    if (poi) poi = _enrichPoi(poi);
596    if (!poi && opts.category) {
597      const list = listByCategory(opts.category);
598      for (const p of list) {
599        const media = await resolveMedia(p);
600        if (media && media.ok) {
601          poi = p;
602          break;
603        }
604      }
605    }
606    if (!poi) {
607      console.warn('[Tourism] Sin POI válido para abrir');
608      return false;
609    }
610
611    const media = await resolveMedia(poi);
612    if (!media || !media.ok) {
613      console.warn('[Tourism] Sin datos suficientes para abrir:', poi.id);
614      if (window.FerrariUI && window.FerrariUI.showToast) {
615        window.FerrariUI.showToast('No pude armar la ficha de ese lugar', 'warning');
616      }
617      return false;
618    }
619
620    const origin = _origin();
621    const distM = _haversineM(origin.lat, origin.lng, media.lat, media.lng);
622    const el = _ensureWidget();
623    el.querySelector('#kpk-tw-title').textContent = poi.title;
624
625    const eyebrow = el.querySelector('#kpk-tw-eyebrow');
626    if (eyebrow) {
627      eyebrow.innerHTML =
628        'Jarvis Turismo' +
629        (media.youtube && media.youtube.id
630          ? ' <span class="kpk-tw-badge">VIDEO</span>'
631          : '');
632    }
633
634    const descParts = [];
635    if (poi.blurb) descParts.push(poi.blurb);
636    if (media.wikiExtract && media.wikiExtract !== poi.blurb) descParts.push(media.wikiExtract);
637    if (Array.isArray(poi.highlights) && poi.highlights.length) {
638      descParts.push(poi.highlights.map((h) => '· ' + h).join(' '));
639    }
640    el.querySelector('#kpk-tw-blurb').textContent = descParts.join('\n\n') || '';
641
642    const metaEl = el.querySelector('#kpk-tw-meta');
643    const prefix =
644      (poi.bandEmoji ? poi.bandEmoji + ' ' + poi.bandLabel + ' · ' : '') +
645      _formatDist(distM) +
646      ' · ' +
647      _formatEta(distM) +
648      ' desde el proyecto';
649    const srcBits = [];
650    if (media.imageUrl) {
651      if (media.imageSource === 'wikipedia' || media.wikiPageUrl) srcBits.push('foto Wikipedia');
652      else if (media.imageSource === 'commons') srcBits.push('foto Commons');
653      else if (media.imageSource === 'catalog') srcBits.push('foto verificada');
654      else srcBits.push('foto');
655    }
656    metaEl.textContent = '';
657    metaEl.appendChild(document.createTextNode(prefix + (srcBits.length ? ' · ' + srcBits.join(' · ') : '')));
658    if (media.youtube && media.youtube.id) {
659      metaEl.appendChild(document.createTextNode(' · '));
660      const vBtn = document.createElement('button');
661      vBtn.type = 'button';
662      vBtn.className = 'kpk-tw-meta-link';
663      vBtn.textContent = 'video curado ▶';
664      vBtn.title = media.youtube.title || 'Ver video';
665      vBtn.addEventListener('click', () => _showTourismVideoPanel(el));
666      metaEl.appendChild(vBtn);
667    }
668
669    _renderTourismMedia(el.querySelector('#kpk-tw-media'), media, poi);
670
671    const footer = el.querySelector('#kpk-tw-footer');
672    let maps = null;
673    if (window.FerrariGeo && typeof window.FerrariGeo.mapsLinks === 'function') {
674      maps = window.FerrariGeo.mapsLinks(media.lat, media.lng);
675    } else {
676      maps = {
677        google:
678          'https://www.google.com/maps/dir/?api=1&destination=' +
679          media.lat +
680          ',' +
681          media.lng,
682        waze: 'https://waze.com/ul?ll=' + media.lat + ',' + media.lng + '&navigate=yes'
683      };
684    }
685    footer.innerHTML =
686      '<button type="button" class="kpk-tw-btn kpk-tw-btn--route" id="kpk-tw-route">Ver ruta en mapa</button>' +
687      '<a class="kpk-tw-btn kpk-tw-btn--maps" href="' +
688      maps.google +
689      '" target="_blank" rel="noopener">Abrir Maps</a>' +
690      '<a class="kpk-tw-btn kpk-tw-btn--waze" href="' +
691      maps.waze +
692      '" target="_blank" rel="noopener">Waze</a>' +
693      '<button type="button" class="kpk-tw-btn kpk-tw-btn--look" id="kpk-tw-look">Ver en 360°</button>' +
694      '<button type="button" class="kpk-tw-btn kpk-tw-btn--next" id="kpk-tw-next">Otro plan cerca</button>' +
695      '<a class="kpk-tw-btn kpk-tw-btn--wa" id="kpk-tw-wa" href="#" target="_blank" rel="noopener">WhatsApp al asesor</a>';
696
697    const openRoute = () => {
698      try {
699        if (window.FerrariUI && typeof window.FerrariUI.openMapWidget === 'function') {
700          window.FerrariUI.openMapWidget(media.lat, media.lng, poi.title);
701        }
702      } catch (e) {}
703    };
704    openRoute();
705
706    const routeBtn = footer.querySelector('#kpk-tw-route');
707    if (routeBtn) routeBtn.onclick = openRoute;
708
709    const lookBtn = footer.querySelector('#kpk-tw-look');
710    if (lookBtn) {
711      lookBtn.onclick = () => {
712        try {
713          const viewer = window.Ferrari && window.Ferrari.viewer;
714          if (window.FerrariGeo && window.FerrariGeo.droneOrigin && viewer) {
715            const brg = window.FerrariGeo.bearingDeg(
716              window.FerrariGeo.droneOrigin.lat,
717              window.FerrariGeo.droneOrigin.lng,
718              media.lat,
719              media.lng
720            );
721            const yaw = typeof window.FerrariGeo.bearingToYaw === 'function'
722              ? window.FerrariGeo.bearingToYaw(brg)
723              : brg;
724            if (typeof viewer.lookAt === 'function') viewer.lookAt(0, yaw, 70, false);
725          }
726          openRoute();
727        } catch (e) {}
728      };
729    }
730
731    const nextBtn = footer.querySelector('#kpk-tw-next');
732    if (nextBtn) {
733      nextBtn.onclick = () => {
734        openNextInCategory(poi.category, poi.id);
735      };
736    }
737
738    const waBtn = footer.querySelector('#kpk-tw-wa');
739    if (waBtn) {
740      const msg =
741        'Hola! Estoy viendo el entorno en el tour 360°. Me gustó ' +
742        poi.title +
743        ' (' +
744        _formatDist(distM) +
745        ' · ' +
746        _formatEta(distM) +
747        ' desde el proyecto). Quiero conocer lotes disponibles cerca.';
748      let waUrl = null;
749      try {
750        const contact =
751          window.FerrariBrandDock && typeof window.FerrariBrandDock.getContact === 'function'
752            ? window.FerrariBrandDock.getContact()
753            : null;
754        const phone = (contact && (contact.whatsapp || contact.platformWhatsapp)) || '';
755        if (window.FerrariBrandDock && typeof window.FerrariBrandDock.whatsappUrl === 'function') {
756          waUrl = window.FerrariBrandDock.whatsappUrl(phone, msg);
757        } else {
758          const digits = String(phone).replace(/\D/g, '');
759          waUrl =
760            'https://api.whatsapp.com/send?phone=' +
761            digits +
762            '&text=' +
763            encodeURIComponent(msg);
764        }
765      } catch (e) {}
766      if (waUrl) {
767        waBtn.href = waUrl;
768      } else {
769        waBtn.style.display = 'none';
770      }
771    }
772
773    // Planes relacionados (otras categorías)
774    let related = el.querySelector('.kpk-tw-related');
775    if (!related) {
776      related = document.createElement('div');
777      related.className = 'kpk-tw-related';
778      el.appendChild(related);
779    }
780    const cats = (_catalog && _catalog.categories) || [];
781    related.innerHTML =
782      '<span class="kpk-tw-related-label">Más planes cerca</span><div class="kpk-tw-related-row"></div>';
783    const row = related.querySelector('.kpk-tw-related-row');
784    cats.forEach((c) => {
785      if (c.id === poi.category) return;
786      const b = document.createElement('button');
787      b.type = 'button';
788      b.className = 'kpk-tw-mini';
789      b.textContent = c.chip || c.label;
790      b.addEventListener('click', () => {
791        if (window.__kpkOfferTourism) window.__kpkOfferTourism(c.id);
792        else prepareOffer(c.id).then(() => {});
793      });
794      row.appendChild(b);
795    });
796
797    el.classList.add('is-open');
798    _openPoiId = poi.id;
799    _pendingOffer = null;
800    try {
801      document.body.classList.add('kpk-tourism-open');
802      _syncTourismLayout();
803    } catch (e) {}
804
805    // Aviso al chat (si existe helper)
806    try {
807      if (window.__kpkTourismOpened) window.__kpkTourismOpened(poi, distM);
808    } catch (e) {}
809
810    return true;
811  }
812
813  /** Oferta conversacional: no abre widget hasta confirmación */
814  async function prepareOffer(category) {
815    const menu = await prepareOfferMenu(category);
816    if (!menu || !menu.items.length) {
817      _pendingOffer = null;
818      return null;
819    }
820    const first = menu.items[0];
821    _pendingOffer = {
822      category: category || first.category,
823      poiId: first.poiId,
824      title: first.title,
825      distLabel: first.distLabel,
826      etaLabel: first.etaLabel,
827      blurb: first.blurb,
828      bandLabel: first.bandLabel
829    };
830    return _pendingOffer;
831  }
832
833  /**
834   * Menú de opciones verificadas, ordenadas de cerca → lejos, agrupadas por banda.
835   * category: id | 'nearest' | '' (todas)
836   */
837  async function prepareOfferMenu(category, opts) {
838    await loadCatalog();
839    const limit = (opts && opts.limit) || 8;
840    const cat = String(category || '').toLowerCase();
841    const source =
842      cat && cat !== 'nearest' && cat !== 'all'
843        ? listByCategory(cat)
844        : (_catalog.pois || [])
845            .map(_enrichPoi)
846            .filter((p) => p.distM <= _maxRadiusM())
847            .sort((a, b) => a.distM - b.distM);
848
849    const items = [];
850    for (const p of source) {
851      if (items.length >= limit) break;
852      const media = await resolveMedia(p);
853      if (!media || !media.ok) continue;
854      items.push({
855        poiId: p.id,
856        category: p.category,
857        title: p.title,
858        distM: p.distM,
859        distKm: p.distKm,
860        distLabel: p.distLabel,
861        etaLabel: p.etaLabel,
862        bandId: p.bandId,
863        bandLabel: p.bandLabel,
864        bandEmoji: p.bandEmoji,
865        blurb: p.blurb,
866        chipLabel:
867          (p.bandEmoji ? p.bandEmoji + ' ' : '') +
868          p.title +
869          ' · ' +
870          p.distLabel
871      });
872    }
873
874    _pendingMenu = {
875      category: cat === 'nearest' || cat === 'all' ? '' : cat,
876      items
877    };
878
879    if (items[0]) {
880      _pendingOffer = {
881        category: items[0].category,
882        poiId: items[0].poiId,
883        title: items[0].title,
884        distLabel: items[0].distLabel,
885        etaLabel: items[0].etaLabel,
886        blurb: items[0].blurb,
887        bandLabel: items[0].bandLabel
888      };
889    } else {
890      _pendingOffer = null;
891    }
892
893    return _pendingMenu;
894  }
895
896  function formatMenuHtml(menu) {
897    if (!menu || !menu.items || !menu.items.length) return '';
898    const byBand = {};
899    const order = [];
900    menu.items.forEach((it) => {
901      const key = it.bandId || 'otros';
902      if (!byBand[key]) {
903        byBand[key] = {
904          emoji: it.bandEmoji || '',
905          label: it.bandLabel || 'Opciones',
906          items: []
907        };
908        order.push(key);
909      }
910      byBand[key].items.push(it);
911    });
912    let html =
913      'Te armo opciones <b>de cerca a lejos</b> con media verificada.';
914    order.forEach((key) => {
915      const g = byBand[key];
916      html +=
917        '<br><br><b>' +
918        (g.emoji ? g.emoji + ' ' : '') +
919        g.label +
920        '</b>';
921      g.items.forEach((it) => {
922        html +=
923          '<br>· <b>' +
924          it.title +
925          '</b> — ' +
926          it.distLabel +
927          ' · ' +
928          it.etaLabel;
929      });
930    });
931    html += '<br><br>¿Cuál te muestro? Elige abajo o di el más cercano.';
932    return html;
933  }
934
935  /** Finde: menú mixed nearest-first */
936  async function prepareNearestOffer() {
937    const menu = await prepareOfferMenu('nearest', { limit: 8 });
938    return menu && menu.items[0]
939      ? {
940          category: menu.items[0].category,
941          poiId: menu.items[0].poiId,
942          title: menu.items[0].title,
943          distLabel: menu.items[0].distLabel,
944          etaLabel: menu.items[0].etaLabel,
945          blurb: menu.items[0].blurb,
946          bandLabel: menu.items[0].bandLabel
947        }
948      : null;
949  }
950
951  function getPendingMenu() {
952    return _pendingMenu;
953  }
954
955  function clearPendingMenu() {
956    _pendingMenu = null;
957  }
958
959  function selectOfferByPoiId(poiId) {
960    if (!_pendingMenu || !_pendingMenu.items) return null;
961    const it = _pendingMenu.items.find((x) => x.poiId === poiId);
962    if (!it) return null;
963    _pendingOffer = {
964      category: it.category,
965      poiId: it.poiId,
966      title: it.title,
967      distLabel: it.distLabel,
968      etaLabel: it.etaLabel,
969      blurb: it.blurb,
970      bandLabel: it.bandLabel
971    };
972    return _pendingOffer;
973  }
974
975  function getChipDefs() {
976    const cats = (_catalog && _catalog.categories) || [];
977    return cats.map((c) => ({
978      text: c.chip || c.label,
979      query: c.id === 'nieve' ? 'quiero ver el volcán y nieve cerca' : 'quiero planes de ' + c.label.toLowerCase() + ' cerca'
980    }));
981  }
982
983  function getPendingOffer() {
984    return _pendingOffer;
985  }
986
987  function clearPendingOffer() {
988    _pendingOffer = null;
989    _pendingMenu = null;
990  }
991
992  async function confirmPendingOffer() {
993    if (!_pendingOffer) return false;
994    const ok = await openWidget({ poiId: _pendingOffer.poiId });
995    _pendingMenu = null;
996    return ok;
997  }
998
999  /** Siguiente POI verificado de la misma categoría (ciclo) */
1000  async function openNextInCategory(category, afterId) {
1001    await loadCatalog();
1002    const list = listByCategory(category);
1003    if (!list.length) return false;
1004
1005    const start = Math.max(0, list.findIndex((p) => p.id === afterId));
1006    for (let i = 1; i <= list.length; i++) {
1007      const p = list[(start + i) % list.length];
1008      if (!p || p.id === afterId) continue;
1009      const media = await resolveMedia(p);
1010      if (media && media.ok) {
1011        return openWidget({ poiId: p.id });
1012      }
1013    }
1014    if (window.FerrariUI && window.FerrariUI.showToast) {
1015      window.FerrariUI.showToast('No hay otro plan verificado en esa categoría', 'info');
1016    }
1017    return false;
1018  }
1019
1020  function catalogSummaryForPrompt() {
1021    if (!_catalog) return '[]';
1022    const origin = _origin();
1023    const rows = (_catalog.pois || []).map((p) => {
1024      const distM = _haversineM(origin.lat, origin.lng, p.lat, p.lng);
1025      return {
1026        id: p.id,
1027        category: p.category,
1028        title: p.title,
1029        km: Math.round((distM / 1000) * 10) / 10
1030      };
1031    });
1032    return JSON.stringify(rows);
1033  }
1034
1035  // Precarga
1036  if (document.readyState === 'loading') {
1037    document.addEventListener('DOMContentLoaded', () => loadCatalog(), { once: true });
1038  } else {
1039    loadCatalog();
1040  }
1041
1042  window.FerrariTourism = {
1043    loadCatalog,
1044    listByCategory,
1045    getPoi,
1046    resolveMedia,
1047    prepareOffer,
1048    prepareOfferMenu,
1049    prepareNearestOffer,
1050    formatMenuHtml,
1051    getPendingOffer,
1052    getPendingMenu,
1053    clearPendingOffer,
1054    clearPendingMenu,
1055    selectOfferByPoiId,
1056    confirmPendingOffer,
1057    openNextInCategory,
1058    openWidget,
1059    closeWidget,
1060    isOpen,
1061    catalogSummaryForPrompt,
1062    getChipDefs,
1063    getCategories: () => (_catalog && _catalog.categories) || [],
1064    getBands: () => _bands()
1065  };
1066
1067  console.log('[Ferrari/Tourism] ✓ Jarvis Turismo listo');
1068})();

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.