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">×</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.