PageSourceSearch

https://altobonito-1.vercel.app/js/core/f-geo.js?v=24

js altobonito-1.vercel.app collected 2026-10-03 06:15:19 UTC 30,096 bytes, 883 lines download raw bytes

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.