1/** 2 * f-geo.js â Estado y matemáticas geoespaciales Ferrari360 3 * 4 * Fuente de verdad: 5 * droneOrigin { lat, lng, label? } 6 * northOffset grados (Pannellum: yaw_norte + northOffset â 0) 7 * pins[] horizonte | ruta | poi 8 * 9 * Persistencia: localStorage['ferrari360_geo'] (+ data/geo.json vÃa Persist) 10 */ 11 12'use strict'; 13 14(function () { 15 16 const STORAGE_KEY = 'ferrari360_geo'; 17 const DIRTY_KEY = 'ferrari360_geo_dirty'; 18 19 const state = { 20 droneOrigin: null, // { lat, lng, label } 21 northOffset: 0, 22 pins: [], 23 updatedAt: null 24 }; 25 26 let _dirty = localStorage.getItem(DIRTY_KEY) === '1'; 27 28 function _markDirty() { 29 _dirty = true; 30 try { localStorage.setItem(DIRTY_KEY, '1'); } catch (e) {} 31 } 32 33 function markClean() { 34 _dirty = false; 35 try { localStorage.removeItem(DIRTY_KEY); } catch (e) {} 36 } 37 38 function isDirty() { return !!_dirty; } 39 40 /** Normaliza lat/lng con precisión estable (~1 cm) y acepta coma decimal */ 41 function _normCoord(n, kind) { 42 if (n == null || n === '') return NaN; 43 if (typeof n === 'string') { 44 const parsed = parseCoordinate(n, kind); 45 if (!isNaN(parsed)) return parsed; 46 } 47 const v = typeof n === 'number' ? n : parseFloat(String(n).trim().replace(',', '.')); 48 if (!isFinite(v)) return NaN; 49 if (kind === 'lat' && (v < -90 || v > 90)) return NaN; 50 if (kind === 'lng' && (v < -180 || v > 180)) return NaN; 51 return Math.round(v * 1e8) / 1e8; 52 } 53 54 function _hemiSign(token, kind) { 55 if (!token) return null; 56 const h = String(token).trim().toUpperCase() 57 .normalize('NFD').replace(/[\u0300-\u036f]/g, ''); 58 if (h === 'S' || h === 'SUR' || h === 'SOUTH') return kind === 'lng' ? null : -1; 59 if (h === 'N' || h === 'NORTE' || h === 'NORTH') return kind === 'lng' ? null : 1; 60 if (h === 'W' || h === 'O' || h === 'OESTE' || h === 'WEST') return kind === 'lat' ? null : -1; 61 if (h === 'E' || h === 'ESTE' || h === 'EAST') return kind === 'lat' ? null : 1; 62 return null; 63 } 64 65 /** 66 * Acepta decimal o DMS, p.ej.: 67 * -41.8764917 | 41°52'35.37"S | 41º 52' 35.37" S | S 41 52 35.37 68 */ 69 function parseCoordinate(raw, kind) { 70 if (raw == null || raw === '') return NaN; 71 if (typeof raw === 'number') { 72 if (!isFinite(raw)) return NaN; 73 if (kind === 'lat' && (raw < -90 || raw > 90)) return NaN; 74 if (kind === 'lng' && (raw < -180 || raw > 180)) return NaN; 75 return Math.round(raw * 1e8) / 1e8; 76 } 77 78 let s = String(raw).trim(); 79 if (!s) return NaN; 80 81 // Coma decimal europea dentro de números (35,37 â 35.37) 82 s = s.replace(/(\d),(\d)/g, '$1.$2'); 83 // Unificar sÃmbolos de grado/minuto/segundo 84 s = s 85 .replace(/[º°]/g, '°') 86 .replace(/[â²â]/g, "'") 87 .replace(/[â³""]/g, '"') 88 .replace(/\bdeg\b/gi, '°'); 89 90 let sign = 1; 91 const hemiRe = /\b(Norte|Sur|Este|Oeste|North|South|East|West|[NSWEO])\b/gi; 92 const hemis = s.match(hemiRe); 93 if (hemis && hemis.length) { 94 for (let i = hemis.length - 1; i >= 0; i--) { 95 const sg = _hemiSign(hemis[i], kind || 'lat'); 96 if (sg != null) { 97 sign = sg; 98 break; 99 } 100 } 101 s = s.replace(hemiRe, ' '); 102 } 103 104 if (/^\s*-/.test(s)) { 105 sign = -1; 106 s = s.replace(/^\s*-/, ''); 107 } else if (/^\s*\+/.test(s)) { 108 s = s.replace(/^\s*\+/, ''); 109 } 110 111 s = s.replace(/[°'"]/g, ' ').replace(/\s+/g, ' ').trim(); 112 const nums = s.match(/\d+(?:\.\d+)?/g); 113 if (!nums || !nums.length) return NaN; 114 115 const d = parseFloat(nums[0]); 116 const m = nums[1] != null ? parseFloat(nums[1]) : 0; 117 const sec = nums[2] != null ? parseFloat(nums[2]) : 0; 118 if (![d, m, sec].every(isFinite)) return NaN; 119 if (m >= 60 || sec >= 60) return NaN; 120 121 let dec; 122 if (nums.length === 1) { 123 dec = sign * Math.abs(d); 124 } else { 125 dec = sign * (Math.abs(d) + m / 60 + sec / 3600); 126 } 127 128 if (kind === 'lat' && (dec < -90 || dec > 90)) return NaN; 129 if (kind === 'lng' && (dec < -180 || dec > 180)) return NaN; 130 return Math.round(dec * 1e8) / 1e8; 131 } 132 133 /** 134 * Par lat/lng: "41°52'35.37\"S 72°44'50.81\"W" | "-41.87, -72.74" | "(...)" 135 */ 136 function parseLatLngPair(raw) { 137 if (raw == null) return null; 138 let s = String(raw).trim(); 139 if (!s) return null; 140 s = s.replace(/^[(\[]+|[)\]]+$/g, '').trim(); 141 s = s.replace(/(\d),(\d)/g, '$1.$2'); 142 143 let latStr = null; 144 let lngStr = null; 145 146 // Corte tras hemisferio de latitud (S/N/Sur/Norteâ¦) 147 const latHemi = s.match(/^(.+?\b(?:Sur|Norte|South|North|S|N)\b)/i); 148 if (latHemi && latHemi[0].length < s.length - 1) { 149 const rest = s.slice(latHemi[0].length).replace(/^[,;\s]+/, '').trim(); 150 if (rest) { 151 latStr = latHemi[1].trim(); 152 lngStr = rest; 153 } 154 } 155 156 if (!latStr || !lngStr) { 157 const byComma = s.split(/[,;]/).map(x =>
157 x.trim()).filter(Boolean); 158 if (byComma.length >= 2) { 159 latStr = byComma[0]; 160 lngStr = byComma.slice(1).join(' ').trim(); 161 } 162 } 163 164 if (!latStr || !lngStr) { 165 const twoDec = s.match(/^([+-]?\d+(?:\.\d+)?)\s+([+-]?\d+(?:\.\d+)?)$/); 166 if (twoDec) { 167 latStr = twoDec[1]; 168 lngStr = twoDec[2]; 169 } 170 } 171 172 if (!latStr || !lngStr) return null; 173 174 const lat = parseCoordinate(latStr, 'lat'); 175 const lng = parseCoordinate(lngStr, 'lng'); 176 if (isNaN(lat) || isNaN(lng)) return null; 177 return { lat, lng }; 178 } 179 180 function _touch() { 181 state.updatedAt = Date.now(); 182 } 183 184 const CATEGORIES = { 185 horizonte: { 186 volcan: { label: 'Volcán', emoji: 'ð' }, 187 ciudad: { label: 'Ciudad', emoji: 'ð' }, 188 lago: { label: 'Lago', emoji: 'ð§' }, 189 montana: { label: 'Montaña', emoji: 'â°' }, 190 mirador: { label: 'Mirador', emoji: 'ð' }, 191 otro: { label: 'Otro', emoji: 'ð' } 192 }, 193 ruta: { 194 acceso: { label: 'Acceso', emoji: 'ð§' }, 195 carretera: { label: 'Carretera', emoji: 'ð£' }, 196 camino: { label: 'Camino', emoji: 'ð¥¾' }, 197 estacionamiento: { label: 'Estacionamiento', emoji: 'ð ¿' }, 198 porton: { label: 'Portón', emoji: 'ðª' }, 199 otro: { label: 'Otro', emoji: 'ð' } 200 }, 201 poi: { 202 hospital: { label: 'Hospital', emoji: 'ð¥' }, 203 consultorio: { label: 'Consultorio', emoji: 'ð©º' }, 204 posta: { label: 'Posta', emoji: 'ð¥' }, 205 sapu: { label: 'SAPU / Urgencia', emoji: 'ð' }, 206 asistencia: { label: 'Asistencia médica', emoji: 'ð' }, 207 farmacia: { label: 'Farmacia', emoji: 'ð' }, 208 comisaria: { label: 'ComisarÃa', emoji: 'ð®' }, 209 reten: { label: 'Retén', emoji: 'ð' }, 210 bomberos: { label: 'Bomberos', emoji: 'ð' }, 211 colegio: { label: 'Colegio', emoji: 'ð«' }, 212 supermercado: { label: 'Supermercado', emoji: 'ð' }, 213 comercio: { label: 'Local comercial', emoji: 'ðª' }, 214 negocio: { label: 'Negocio', emoji: 'ð·ï¸' }, 215 bencinera: { label: 'Bencinera', emoji: 'â½' }, 216 otro: { label: 'Otro', emoji: 'ð' } 217 }, 218 amenidad: { 219 laguna: { label: 'Laguna', emoji: 'â' }, 220 lago: { label: 'Lago', emoji: 'â' }, 221 estero: { label: 'Estero', emoji: 'â' }, 222 cascada: { label: 'Cascada', emoji: 'â' }, 223 playa: { label: 'Playa', emoji: 'â' }, 224 muelle: { label: 'Muelle', emoji: 'â' }, 225 embarcadero: { label: 'Embarcadero', emoji: 'â' }, 226 boya: { label: 'Boya', emoji: 'â' }, 227 embarcacion: { label: 'Embarcaciones', emoji: 'â' }, 228 kayak: { label: 'Kayak', emoji: 'â' }, 229 pesca: { label: 'Pesca', emoji: 'â' }, 230 club_nautico: { label: 'Club náutico', emoji: 'â' }, 231 bosque: { label: 'Bosque', emoji: 'â' }, 232 sendero: { label: 'Sendero', emoji: 'â' }, 233 mirador: { label: 'Mirador', emoji: 'â' }, 234 fauna: { label: 'Fauna', emoji: 'â' }, 235 humedal: { label: 'Humedal', emoji: 'â' }, 236 reserva: { label: 'Reserva', emoji: 'â' }, 237 caballos: { label: 'Caballos', emoji: 'â' }, 238 bike: { label: 'Bicicleta', emoji: 'â' }, 239 picnic: { label: 'Picnic', emoji: 'â' }, 240 fogon: { label: 'Fogón', emoji: 'â' }, 241 camping: { label: 'Camping', emoji: 'â' }, 242 trekking: { label: 'Trekking', emoji: 'â' }, 243 plaza: { label: 'Plaza', emoji: 'â' }, 244 clubhouse: { label: 'Clubhouse', emoji: 'â' }, 245 cancha: { label: 'Cancha', emoji: 'â' }, 246 quincho: { label: 'Quincho', emoji: 'â' }, 247 porteria: { label: 'PorterÃa', emoji: 'â' }, 248 estacionamiento_interno: { label: 'Estacionamiento', emoji: 'â' }, 249 acceso_loteo: { label: 'Acceso loteo', emoji: 'â' }, 250 agua_potable: { label: 'Agua potable', emoji: 'â' }, 251 energia: { label: 'EnergÃa', emoji: 'â' }, 252 senal: { label: 'Señal / WiFi', emoji: 'â' }, 253 otro: { label: 'Otro', emoji: 'â' } 254 } 255 }; 256 257 // Merge runtime catalog if loaded first 258 if (window.FerrariAmenitiesCatalog && window.FerrariAmenitiesCatalog.toGeoCategories) { 259 CATEGORIES.amenidad = window.FerrariAmenitiesCatalog.toGeoCategories(); 260 } 261 262 // âââ Math âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 263 264 function haversineM(lat1, lng1, lat2, lng2) { 265 const R = 6371000; 266 const toR = Math.PI / 180; 267 const dLat = (lat2 - lat1) * toR; 268 const dLng = (lng2 - lng1) * toR; 269 const a = Math.sin(dLat / 2) ** 2 + 270 Math.cos(lat1 * toR) * Math.cos(lat2 * toR) * Math.sin(dLng / 2) ** 2; 271 return 2 * R * Math.asin(Math.min(1, Math.sqrt(a))); 272 } 273 274 /** Bearing inicial en grados [0, 360): 0 = norte, 90 = este */ 275 function bearingDeg(lat1, lng1, lat2, lng2) { 276 const toR = Math.PI / 180; 277 const Ï1 = lat1 * toR, Ï2 = lat2 * toR; 278 const Îλ = (lng2 - lng1) * toR; 279 const y = Math.sin(Îλ) * Math.cos(Ï2); 280 const x = Math.cos(Ï1) * Math.sin(Ï2) - Math.sin(Ï1) * Math.cos(Ï2) * Math.cos(Îλ); 281 return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360; 282 } 283 284 /** 285 * Convierte bearing geográfico â yaw de Pannellum 286 * (con northOffset calibrado: mirar al norte â yaw â -northOffset) 287 */ 288 function bearingToYaw(bearing) {
289 // yaw = bearing - 360? Facing north: yaw + northOffset = 0 290 // Facing bearing B: yaw + northOffset = B â yaw = B - northOffset 291 let yaw = bearing - state.northOffset; 292 yaw = ((yaw + 540) % 360) - 180; 293 return yaw; 294 } 295 296 function yawToBearing(yaw) { 297 let b = yaw + state.northOffset; 298 b = ((b % 360) + 360) % 360; 299 return b; 300 } 301 302 function formatDistance(meters) { 303 if (meters == null || isNaN(meters)) return 'â'; 304 if (meters < 1000) return `${Math.round(meters)} m`; 305 if (meters < 10000) return `${(meters / 1000).toFixed(2)} km`; 306 return `${(meters / 1000).toFixed(1)} km`; 307 } 308 309 /** ETA aproximada en auto urbano (~45 km/h) â fallback lÃnea recta */ 310 function formatEtaMinutes(meters, kmh) { 311 if (meters == null || isNaN(meters)) return 'â'; 312 const speed = kmh || 45; 313 const min = (meters / 1000) / speed * 60; 314 if (min < 1) return '< 1 min'; 315 if (min < 60) return `â ${Math.round(min)} min`; 316 const h = Math.floor(min / 60); 317 const m = Math.round(min % 60); 318 return `â ${h} h ${m} min`; 319 } 320 321 /** ETA desde duración real de ruta (segundos OSRM) */ 322 function formatEtaSeconds(sec) { 323 if (sec == null || isNaN(sec)) return 'â'; 324 const min = sec / 60; 325 if (min < 1) return '< 1 min'; 326 if (min < 60) return `${Math.max(1, Math.round(min))} min`; 327 const h = Math.floor(min / 60); 328 const m = Math.round(min % 60); 329 return m ? `${h} h ${m} min` : `${h} h`; 330 } 331 332 /** 333 * Horizonte = landmark visual, pero si tiene GPS (ej. Chaitén) sà conviene 334 * ruta OSRM real. La UI debe etiquetar "lÃnea recta" vs "ruta en auto" 335 * para que el ETA no parezca âcambiar soloâ. 336 */ 337 function usesDrivingRoute(pin) { 338 return !!(pin && pin.lat != null && pin.lng != null); 339 } 340 341 /** Texto Distancia / ETA para UI (pins, editor, dock) */ 342 function formatPinDistanceEta(pin) { 343 if (!pin) return 'â'; 344 const routeDist = pin._routeDistM != null ? pin._routeDistM : pin.routeDistM; 345 const routeSec = pin._routeSec != null ? pin._routeSec : pin.routeSec; 346 const hasRoute = routeDist != null && routeSec != null; 347 const hasAir = pin._distM != null; 348 if (hasRoute) { 349 return `Ruta ${formatDistance(routeDist)} · ${formatEtaSeconds(routeSec)}`; 350 } 351 if (hasAir) { 352 const base = `â ${formatDistance(pin._distM)} · ${formatEtaMinutes(pin._distM)}`; 353 return pin.tipo === 'horizonte' ? `${base} (calculando rutaâ¦)` : base; 354 } 355 return 'â'; 356 } 357 358 function _clearDrivingRoute(pin) { 359 if (!pin) return; 360 pin._routeDistM = null; 361 pin._routeSec = null; 362 pin._routeSource = null; 363 if (pin._routeDurationS != null) pin._routeDurationS = null; 364 } 365 366 function _syncRouteRuntime(pin) { 367 if (!pin) return; 368 if (pin.routeDistM != null && pin.routeSec != null) { 369 pin._routeDistM = pin.routeDistM; 370 pin._routeSec = pin.routeSec; 371 pin._routeSource = pin.routeSource || 'osrm'; 372 } 373 } 374 375 // âââ Rutas reales (OSRM / OpenStreetMap: calles + ferries mapeados) ââ 376 const _routeCache = new Map(); 377 let _routeQueue = Promise.resolve(); 378 const OSRM_URL = 'https://router.project-osrm.org/route/v1/driving'; 379 380 function _routeKey(oLat, oLng, dLat, dLng) { 381 return [ 382 oLat.toFixed(4), oLng.toFixed(4), 383 dLat.toFixed(4), dLng.toFixed(4) 384 ].join('|'); 385 } 386 387 function fetchDrivingRoute(destLat, destLng) { 388 const o = state.droneOrigin; 389 if (!o || destLat == null || destLng == null) return Promise.resolve(null); 390 const key = _routeKey(o.lat, o.lng, destLat, destLng); 391 if (_routeCache.has(key)) return Promise.resolve(_routeCache.get(key)); 392 393 _routeQueue = _routeQueue.then(async () => { 394 if (_routeCache.has(key)) return _routeCache.get(key); 395 try { 396 const url = `${OSRM_URL}/${o.lng},${o.lat};${destLng},${destLat}?overview=false&alternatives=false&steps=false`; 397 const res = await fetch(url); 398 if (!res.ok) throw new Error('OSRM ' + res.status); 399 const data = await res.json(); 400 const route = data && data.routes && data.routes[0]; 401 if (!route) { 402 _routeCache.set(key, null); 403 return null; 404 } 405 const out = { 406 distM: route.distance, 407 durationSec: route.duration, 408 source: 'osrm' 409 }; 410 _routeCache.set(key, out); 411 return out; 412 } catch (e) { 413 console.warn('[Ferrari/Geo] Ruta OSRM no disponible:', e.message); 414 _routeCache.set(key, null); 415 return null; 416 } 417 }); 418 return _routeQueue; 419 } 420 421 async function enrichPinRoutes(force) { 422 if (!state.droneOrigin) return; 423 const pins = state.pins.filter(p => 424 p.lat != null && p.lng != null && usesDrivingRoute(p) 425 );
426 let changed = false; 427 for (const pin of pins) { 428 // Ignorar overrides viejos (Google/manual): siempre recalcular OSRM 429 if (pin.routeManual || pin.routeSource === 'google') { 430 pin.routeManual = false; 431 pin.routeSource = null; 432 } 433 if (!force && pin._routeDistM != null && pin._routeSec != null && pin._routeSource === 'osrm') continue; 434 const route = await fetchDrivingRoute(pin.lat, pin.lng); 435 if (route) { 436 pin._routeDistM = route.distM; 437 pin._routeSec = route.durationSec; 438 pin._routeSource = route.source; 439 pin.routeDistM = route.distM; 440 pin.routeSec = route.durationSec; 441 pin.routeSource = route.source; 442 pin.routeManual = false; 443 changed = true; 444 } 445 } 446 if (changed && window.FerrariGeoPins && window.FerrariGeoPins.markDirty) { 447 window.FerrariGeoPins.markDirty(); 448 } 449 } 450 451 function distanceFromOrigin(lat, lng) { 452 if (!state.droneOrigin) return null; 453 return haversineM(state.droneOrigin.lat, state.droneOrigin.lng, lat, lng); 454 } 455 456 function mapsLinks(destLat, destLng) { 457 const o = state.droneOrigin; 458 const dest = `${destLat},${destLng}`; 459 const origin = o ? `${o.lat},${o.lng}` : ''; 460 return { 461 google: origin 462 ? `https://www.google.com/maps/dir/?api=1&origin=${encodeURIComponent(origin)}&destination=${encodeURIComponent(dest)}&travelmode=driving` 463 : `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(dest)}`, 464 waze: `https://waze.com/ul?ll=${encodeURIComponent(dest)}&navigate=yes`, 465 place: `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(dest)}`, 466 satellite: `https://www.google.com/maps/@${destLat},${destLng},17z/data=!3m1!1e3` 467 }; 468 } 469 470 // âââ CRUD âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 471 472 function _notify(detail) { 473 if (window.FerrariGeoPins && window.FerrariGeoPins.markDirty) { 474 window.FerrariGeoPins.markDirty(); 475 } 476 if (window.FerrariCompass && window.FerrariCompass.refresh) { 477 window.FerrariCompass.refresh(); 478 } 479 try { 480 document.dispatchEvent(new CustomEvent('ferrari:geo-changed', { detail: detail || null })); 481 } catch (e) {} 482 } 483 484 /** 485 * Elimina los resultados de la herramienta Cercanos. 486 * Los pines de horizonte y rutas creados manualmente se conservan. 487 */ 488 function _clearNearbyPins() { 489 const before = state.pins.length; 490 state.pins = state.pins.filter(pin => pin.tipo !== 'poi'); 491 return before - state.pins.length; 492 } 493 494 function setDroneOrigin(lat, lng, label) { 495 const la = _normCoord(lat, 'lat'); 496 const ln = _normCoord(lng, 'lng'); 497 if (isNaN(la) || isNaN(ln)) return false; 498 const previousOrigin = state.droneOrigin 499 ? { lat: state.droneOrigin.lat, lng: state.droneOrigin.lng } 500 : null; 501 const clearedNearby = _clearNearbyPins(); 502 state.droneOrigin = { 503 lat: la, 504 lng: ln, 505 label: (label || 'Origen dron').trim() 506 }; 507 _touch(); 508 _markDirty(); 509 _recomputePinMetrics(); 510 saveLocal(); 511 _notify({ 512 reason: 'origin-changed', 513 previousOrigin, 514 origin: { lat: la, lng: ln }, 515 clearedNearby 516 }); 517 return true; 518 } 519 520 function clearDroneOrigin() { 521 const clearedNearby = _clearNearbyPins(); 522 state.droneOrigin = null; 523 _touch(); 524 _markDirty(); 525 _recomputePinMetrics(); 526 saveLocal(); 527 _notify({ reason: 'origin-cleared', origin: null, clearedNearby }); 528 } 529 530 function setNorthOffset(offsetDeg) { 531 state.northOffset = ((offsetDeg % 360) + 360) % 360; 532 if (state.northOffset > 180) state.northOffset -= 360; 533 // Aplicar a Pannellum 534 try { 535 if (window.Ferrari && window.Ferrari.viewer && window.Ferrari.viewer.setNorthOffset) { 536 window.Ferrari.viewer.setNorthOffset(state.northOffset); 537 } 538 } catch (e) {} 539 // Reposicionar pins con coords GPS según nuevo norte 540 _syncPinsYawFromCoords(); 541 _touch(); 542 _markDirty(); 543 saveLocal(); 544 _notify(); 545 } 546 547 /** Fija el norte haciendo click en un yaw del panorama */ 548 function setNorthFromYaw(yaw) { 549 // Queremos que ese yaw sea "norte" â yaw + northOffset = 0 550 setNorthOffset(-yaw); 551 } 552 553 function addPin(data) { 554 const pin = Object.assign({ 555 id: (window.FerrariState && window.FerrariState.generateId) 556 ? window.FerrariState.generateId() 557 : ('geo_' + Math.random().toString(36).slice(2, 10)), 558 tipo: 'horizonte', 559 categoria: 'otro', 560 titulo: '', 561 pitch: -8, 562 yaw: 0, 563 lat: null, 564 lng: null, 565 notas: '', 566 createdAt: Date.now() 567 }, data || {}); 568 569 if (pin.lat != null && pin.lng != null && state.droneOrigin) { 570 const dist = distanceFromOrigin(pin.lat, pin.lng); 571 pin._distM = dist; 572 const brg = bearingDeg(state.droneOrigin.lat, state.droneOrigin.lng, pin.lat, pin.lng); 573 pin._bearing = brg; 574 if (data && data.autoYaw !== false) { 575 pin.yaw = bearingToYaw(brg); 576 } 577 if (usesDrivingRoute(pin)) { 578 fetchDrivingRoute(pin.lat, pin.lng).then(route => { 579 if (!route) return; 580 pin._routeDistM = route.distM; 581 pin._routeSec = route.durationSec; 582 pin._routeSource = route.source; 583 pin.routeDistM = route.distM; 584 pin.routeSec = route.durationSec; 585 pin.routeSource = route.source; 586 pin.routeManual = false;
587 if (window.FerrariGeoPins && window.FerrariGeoPins.markDirty) { 588 window.FerrariGeoPins.markDirty(); 589 } 590 }).catch(() => {}); 591 } else { 592 _clearDrivingRoute(pin); 593 } 594 } 595 596 state.pins.push(pin); 597 _touch(); 598 _markDirty(); 599 saveLocal(); 600 _notify(); 601 return pin.id; 602 } 603 604 function updatePin(id, patch) { 605 const pin = state.pins.find(p => p.id === id); 606 if (!pin) return false; 607 Object.assign(pin, patch); 608 if (pin.lat != null && pin.lng != null && state.droneOrigin) { 609 pin._distM = distanceFromOrigin(pin.lat, pin.lng); 610 pin._bearing = bearingDeg(state.droneOrigin.lat, state.droneOrigin.lng, pin.lat, pin.lng); 611 if (!usesDrivingRoute(pin)) { 612 _clearDrivingRoute(pin); 613 } else if (patch.lat != null || patch.lng != null || patch.tipo != null) { 614 pin.routeManual = false; 615 pin._routeDistM = null; 616 pin._routeSec = null; 617 fetchDrivingRoute(pin.lat, pin.lng).then(route => { 618 if (!route) return; 619 pin._routeDistM = route.distM; 620 pin._routeSec = route.durationSec; 621 pin._routeSource = route.source; 622 pin.routeDistM = route.distM; 623 pin.routeSec = route.durationSec; 624 pin.routeSource = route.source; 625 pin.routeManual = false; 626 if (window.FerrariGeoPins && window.FerrariGeoPins.markDirty) { 627 window.FerrariGeoPins.markDirty(); 628 } 629 }).catch(() => {}); 630 } else { 631 _syncRouteRuntime(pin); 632 } 633 } else { 634 pin._distM = null; 635 pin._bearing = null; 636 _clearDrivingRoute(pin); 637 } 638 _touch(); 639 _markDirty(); 640 saveLocal(); 641 _notify(); 642 return true; 643 } 644 645 function removePin(id) { 646 const i = state.pins.findIndex(p => p.id === id); 647 if (i === -1) return false; 648 state.pins.splice(i, 1); 649 _touch(); 650 _markDirty(); 651 saveLocal(); 652 _notify(); 653 return true; 654 } 655 656 function getPin(id) { 657 return state.pins.find(p => p.id === id) || null; 658 } 659 660 function _recomputePinMetrics() { 661 state.pins.forEach(pin => { 662 if (pin.lat != null && pin.lng != null && state.droneOrigin) { 663 pin._distM = distanceFromOrigin(pin.lat, pin.lng); 664 pin._bearing = bearingDeg(state.droneOrigin.lat, state.droneOrigin.lng, pin.lat, pin.lng); 665 _syncRouteRuntime(pin); 666 } else { 667 pin._distM = null; 668 pin._bearing = null; 669 pin._routeDistM = null; 670 pin._routeSec = null; 671 } 672 }); 673 // Rutas reales en segundo plano (calles / ferries OSM) 674 enrichPinRoutes(true).catch(() => {}); 675 } 676 677 function _syncPinsYawFromCoords() { 678 if (!state.droneOrigin) return; 679 state.pins.forEach(pin => { 680 if (pin.lat == null || pin.lng == null) return; 681 if (pin.lockYaw) return; // usuario arrastró y bloqueó 682 pin.yaw = bearingToYaw(pin._bearing != null 683 ? pin._bearing 684 : bearingDeg(state.droneOrigin.lat, state.droneOrigin.lng, pin.lat, pin.lng)); 685 }); 686 } 687 688 // âââ Persistencia âââââââââââââââââââââââââââââââââââââââââââââââââ 689 690 function toJSON() { 691 return { 692 version: 1, 693 updatedAt: state.updatedAt || null, 694 droneOrigin: state.droneOrigin 695 ? { 696 lat: state.droneOrigin.lat, 697 lng: state.droneOrigin.lng, 698 label: state.droneOrigin.label || 'Origen dron' 699 } 700 : null, 701 northOffset: state.northOffset, 702 pins: state.pins.map(p => ({ 703 id: p.id, 704 tipo: p.tipo, 705 categoria: p.categoria, 706 titulo: p.titulo, 707 pitch: p.pitch, 708 yaw: p.yaw, 709 lat: p.lat, 710 lng: p.lng, 711 notas: p.notas || '', 712 icon: p.icon || null, 713 grupo: p.grupo || null, 714 scale: p.scale != null ? p.scale : 1, 715 lockYaw: !!p.lockYaw, 716 createdAt: p.createdAt, 717 // Ruta persistida (Google/manual/OSRM) â sobrevive a recargas 718 routeDistM: p.routeDistM != null ? p.routeDistM : (p._routeDistM != null ? p._routeDistM : null),
719 routeSec: p.routeSec != null ? p.routeSec : (p._routeSec != null ? p._routeSec : null), 720 routeSource: p.routeSource || p._routeSource || null, 721 routeManual: !!p.routeManual 722 })) 723 }; 724 } 725 726 function fromJSON(data) { 727 if (!data || typeof data !== 'object') return; 728 let origin = data.droneOrigin || null; 729 if (origin && origin.lat != null && origin.lng != null) { 730 const la = _normCoord(origin.lat, 'lat'); 731 const ln = _normCoord(origin.lng, 'lng'); 732 origin = isNaN(la) || isNaN(ln) 733 ? null 734 : { lat: la, lng: ln, label: (origin.label || 'Origen dron').trim() }; 735 } else { 736 origin = null; 737 } 738 state.droneOrigin = origin; 739 state.northOffset = typeof data.northOffset === 'number' ? data.northOffset : 0; 740 state.pins = Array.isArray(data.pins) ? data.pins.slice() : []; 741 state.updatedAt = typeof data.updatedAt === 'number' ? data.updatedAt : null; 742 _recomputePinMetrics(); 743 try { 744 if (window.Ferrari && window.Ferrari.viewer && window.Ferrari.viewer.setNorthOffset) { 745 window.Ferrari.viewer.setNorthOffset(state.northOffset); 746 } 747 } catch (e) {} 748 _notify(); 749 } 750 751 /** 752 * Aplica geo remoto sin pisar un origen local pendiente de publicar (dirty) 753 * ni un origen local más reciente cuando el remoto viene vacÃo/antiguo. 754 */ 755 function applyRemote(remote) { 756 if (!remote || typeof remote !== 'object') return false; 757 758 if (_dirty) { 759 console.log('[Ferrari/Geo] Remoto no aplica: hay cambios locales sin Guardar (origen/norte/pins).'); 760 return false; 761 } 762 763 let local = null; 764 try { 765 const raw = localStorage.getItem(STORAGE_KEY); 766 if (raw) local = JSON.parse(raw); 767 } catch (e) {} 768 769 const remoteOrigin = remote.droneOrigin || null; 770 const localOrigin = (local && local.droneOrigin) || state.droneOrigin || null; 771 const localTs = (local && local.updatedAt) || state.updatedAt || 0; 772 const remoteTs = remote.updatedAt || 0; 773 774 const merged = Object.assign({}, remote); 775 776 // Nunca borrar un origen local válido con un remoto vacÃo 777 if (localOrigin && !remoteOrigin) { 778 merged.droneOrigin = localOrigin; 779 if (local && typeof local.northOffset === 'number') { 780 merged.northOffset = local.northOffset; 781 } 782 console.log('[Ferrari/Geo] Conservando origen local (remoto sin coordenadas).'); 783 } else if (localOrigin && remoteOrigin && localTs > remoteTs) { 784 merged.droneOrigin = localOrigin; 785 if (local && typeof local.northOffset === 'number') { 786 merged.northOffset = local.northOffset; 787 } 788 console.log('[Ferrari/Geo] Conservando origen local más reciente que el remoto.'); 789 } 790 791 fromJSON(merged); 792 // Persistencia limpia (ya sincronizado con remoto/merge) â no marcar dirty 793 try { 794 localStorage.setItem(STORAGE_KEY, JSON.stringify(toJSON())); 795 } catch (e) {} 796 markClean(); 797 return true; 798 } 799 800 function saveLocal() { 801 try { 802 localStorage.setItem(STORAGE_KEY, JSON.stringify(toJSON())); 803 // Snapshot unificado para no perder origen entre recargas 804 try { 805 const prev = localStorage.getItem('ferrari360_datos'); 806 const pack = prev ? JSON.parse(prev) : { version: 1, lotes: [] }; 807 pack.geo = toJSON(); 808 pack.updatedAt = Date.now(); 809 localStorage.setItem('ferrari360_datos', JSON.stringify(pack)); 810 } catch (e) {} 811 } catch (e) { 812 console.warn('[Ferrari/Geo] No se pudo guardar local', e); 813 } 814 } 815 816 function loadLocal() { 817 try { 818 const raw = localStorage.getItem(STORAGE_KEY); 819 if (!raw) return; 820 fromJSON(JSON.parse(raw)); 821 _dirty = localStorage.getItem(DIRTY_KEY) === '1'; 822 } catch (e) {} 823 } 824 825 function categoryMeta(tipo, categoria) { 826 const bag = CATEGORIES[tipo] || CATEGORIES.horizonte; 827 return bag[categoria] || bag.otro || { label: categoria, emoji: 'ð' }; 828 } 829 830 // âââ API ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 831 window.FerrariGeo = { 832 STORAGE_KEY, 833 CATEGORIES, 834 get state() { return state; }, 835 get droneOrigin() { return state.droneOrigin; }, 836 get northOffset() { return state.northOffset; }, 837 get pins() { return state.pins; }, 838 839 haversineM, 840 bearingDeg, 841 bearingToYaw, 842 yawToBearing, 843 formatDistance, 844 formatEtaMinutes, 845 formatEtaSeconds, 846 formatPinDistanceEta, 847 usesDrivingRoute, 848 fetchDrivingRoute, 849 enrichPinRoutes, 850 distanceFromOrigin, 851 mapsLinks, 852 categoryMeta, 853 parseCoordinate, 854 parseLatLngPair, 855 856 setDroneOrigin, 857 clearDroneOrigin, 858 setNorthOffset, 859 setNorthFromYaw, 860 addPin, 861 updatePin, 862 removePin, 863 getPin, 864 865 toJSON, 866 fromJSON, 867 applyRemote, 868 saveLocal, 869 loadLocal, 870 isDirty, 871 markClean 872 }; 873 874 // Auto-load 875 if (document.readyState === 'loading') { 876 document.addEventListener('DOMContentLoaded', loadLocal, { once: true }); 877 } else { 878 loadLocal(); 879 } 880 881 console.log('[Ferrari/Geo] â Módulo inicializado'); 882 883})();
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.