Source: r3.js

/* jshint esversion: 6 */
const MAP_MODE = {WINDY:0, OSM:1, LOCAL:2};
let mapMode = MAP_MODE.WINDY;

let animation = false;
let barbDisp = true;    // diplay barbs in OSM mode
let clipBoard = false;  // to set request in clipBoard
let windCanvas = {};
let gribLimits = {
   bottomLat: 0,
   rightLon: 0,
   leftLon: 0,
   topLat: 0,
   name : "",      // wind grib name
   currentName: "" // current grib name
}; 
let moduloIsoc = 1;
let gribRectangle = null;
let polarName = "Ultim.csv";
let polWaveName = "polwave.csv";
let isoDescMarkers = [];

let competitors = [
  { name: "pistache", lat: 46, lon: -3, color: 0, marker: {}},
  { name: "jojo",     lat: 47, lon: -4, color: 1, marker: {}},
  { name: "titi",     lat: 48, lon: -5, color: 2, marker: {}}
];

let routeParam = {
   iBoat: 1,               // 0 reserved for all boats
   isoStep: 900,           // 15 mn
   startTime: new Date (),
   nTry: 0,                // 0 equivalent to 1 try
   timeInterval: 0,        // 0 means not used
   epochStart: 0,          // Unix time in seconds
   polar: `pol/${polarName}`,
   forbid: true,
   isoc: true,
   isoDesc: false,
   xWind: 1,
   maxWind: 100,
   penalty0: 180,
   penalty1: 180,
   penalty2: 180,
   motorSpeed: 2,
   threshold: 2,
   dayEfficiency: 1.0,
   nightEfficiency: 1.0,
   initialAmure: 0,
   cogStep: 2,
   cogRange: 90,
   jFactor: 50,
   kFactor: 40,
   nSectors: 720,
   model: "GFS",
   staminaVR: 100,
   constWindTws: 0, constWindTwd:0, constWave:0, constCurrentS:0, constCurrentD: 0
};

const rCubeInfo = "© René Rigault";
let index = 0;
const options = {
   key: rCubeKey,
   lat: competitors [0].lat,                                
   lon: competitors [0].lon,
   zoom: 4,
   latlon: true,
   timeControl: true,
   model: 'gfs'
};
let bounds;
let marker;
let map;
let portsLayer = null; // for addPorts
let layersControl = null; // for addPorts

let store;
let destination = null;
let orthoRoute = null;
let orthoRouteGroup;
// tables init
let POIs = [];
let myWayPoints = [];   // contains waypoint up to destination not origin
let route = null;       // global variable storing route
let isochroneLayerGroup;
let bestTimeResult = [];
let initialStartTime;
let bestStartTime;
let compResult = [];
let lastPointLayer;

//window.oldRoutes = [];


// Define color mapping based on index
const colorMap = ["red", "green", "blue", "orange", "black"];

// equivalent server side: enum {REQ_TEST, REQ_ROUTING, ...}; // type of request
const REQ = {TEST: 0, ROUTING: 1, COORD: 2, FORBID_ZONE: 3, POLAR: 4, 
             GRIB: 5, DIR: 6, PAR_RAW: 7, PAR_JSON: 8,
             INIT: 9, FEEDBACK:10, DUMP_FILE:11, NEAREST_PORT:12, MARKS: 13, GRIB_CHECK: 14, GPX_ROUTE: 15, GRIB_DUMP: 16}; 

const MARKER = encodeURIComponent(`<?xml version="1.0" encoding="UTF-8" standalone="no"?>
        <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
        <svg width="100%" height="100%" viewBox="0 0 14 14" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
        <path d="M4.784,13.635c0,0 -0.106,-2.924 0.006,-4.379c0.115,-1.502 0.318,-3.151 0.686,-4.632c0.163,-0.654 0.45,-1.623 0.755,-2.44c0.202,-0.54 0.407,-1.021 0.554,-1.352c0.038,-0.085 0.122,-0.139 0.215,-0.139c0.092,0 0.176,0.054 0.214,0.139c0.151,0.342 0.361,0.835 0.555,1.352c0.305,0.817 0.592,1.786 0.755,2.44c0.368,1.481 0.571,3.13 0.686,4.632c0.112,1.455 0.006,4.379 0.006,4.379l-4.432,0Z" style="fill:rgb(0,46,252);"/><path d="M5.481,12.731c0,0 -0.073,-3.048 0.003,-4.22c0.06,-0.909 0.886,-3.522 1.293,-4.764c0.03,-0.098 0.121,-0.165 0.223,-0.165c0.103,0 0.193,0.067 0.224,0.164c0.406,1.243 1.232,3.856 1.292,4.765c0.076,1.172 0.003,4.22 0.003,4.22l-3.038,0Z" style="fill:rgb(255,255,255);fill-opacity:0.846008;"/>
    </svg>`);
const MARKER_ICON_URL = `data:image/svg+xml;utf8,${MARKER}`;

const BoatIcon = L.icon ({
   iconUrl: MARKER_ICON_URL,
   iconSize: [24, 24],
   iconAnchor: [12, 12],
   popupAnchor: [0, 0],
});

const MAX_N = 400; // number max of isochrone for requestTwo and adjustIsoStep
const timeSteps = [900, 1800, 3600, 7200, 10800]; // for adjustIsoStep

/** adjust isoStep value of routparam object
  * return true if value has been changed, false otherwise
  * new isoStep value is choosen in order that:
    new >= routeParam.isoStep and new >= t = duration / MAX_N.
  */
function adjustIsoStep(routeParam, duration) {
   const t = duration / MAX_N;
   const oldTimeStep = routeParam.isoStep;
   const minRequired = Math.max(oldTimeStep, t);

   // By default, the biggest
   let chosen = timeSteps[timeSteps.length - 1];

   for (const step of timeSteps) {
      if (step >= minRequired) {
         chosen = step;   // premier qui satisfait
         break;
      }
   }
   routeParam.isoStep = chosen;
   return routeParam.isoStep !== oldTimeStep;
}

/**
 *Give the date associated with index
 * @param {number} index - Inde
 * @returns {Date} The date
 * should consider the case with waypoints that influence time
 */
function getDateFromIndex (index, boatName) {
   if (!route || !route [boatName] || !route [boatName].track) {
      console.error ("Invalid data route !");
      return;
   }
   let [wp, lat, lon, time, dist, sog, twd, tws, hdg, twa, g, w, stamina, sail, motor] = route [boatName].track [index];
   //let theTime = new Date(routeParam.startTime.getTime() + index * routeParam.isoStep * 1000);
   let theTime = new Date(routeParam.startTime.getTime() + time * 1000);
   return theTime;
}

/**
 * Give visibility to tool bars only when route is active.
 */
function updateToolsVisibility() {
   const toolsDiv = document.getElementById('tools');
   if (route && Object.keys(route).length > 0) {
      toolsDiv.style.display = 'block';
      const isocContEl = document.getElementById('isocCont');
      if (routeParam.isoc && compResult.length === 0) isocContEl.style.display = 'inline'; 
      else isocContEl.style.display = 'none';
   } else {
      toolsDiv.style.display = 'none';
   }
}

/*
 * to display or not display windy timeline 
 */ 
function setTimelineVisible(visible) {
   const windyEl = document.getElementById('windy');
   if (!windyEl) return;
    windyEl.classList.toggle('no-timeline', !visible);
}

/**
 * Saves the current state of competitors and polarName to localStorage.
 * Only simple serializable fields are stored (name, lat, lon, color).
 */
function saveAppState() {
   const cleanCompetitors = competitors.map(c => ({
      name: c.name,
      lat: c.lat,
      lon: c.lon,
      color: c.color
   }));

   localStorage.setItem("competitors", JSON.stringify(cleanCompetitors));
   localStorage.setItem("polarName", polarName);
   localStorage.setItem("POIs", JSON.stringify(getSerializablePOIs()));
   localStorage.setItem("myWayPoints", JSON.stringify(myWayPoints));
   localStorage.setItem("mapMode", mapMode);
   localStorage.setItem("routeParam", JSON.stringify(routeParam));
}

/**
 * Show point waypoints and destination
 * Give the right direction to boats toward first waypoint if requested
 * @param {Array} wayPoints - List of waypoints as [latitude, longitude].
 */
function showWayPoint (wayPoints, headingRequested = true) {
   if (wayPoints.length > 0) {
      for (let boat of competitors) {
         drawOrtho (boat, wayPoints);
         if (headingRequested) {
            let heading = orthoCap (boat.lat, boat.lon, wayPoints [0][0], wayPoints [0][1]);
            // alert (`name: ${boat.name}, heading: ${heading}`);
            boat.marker.setLatLng ([boat.lat, boat.lon]);                   // Move the mark
            boat.marker._icon.setAttribute ('data-heading', heading); 
            updateIconStyle (boat.marker);
         }
      }
      const lat = myWayPoints [myWayPoints.length - 1][0];
      const lon = myWayPoints [myWayPoints.length - 1][1];
      showDestination (lat, lon);
   }
}

/**
 * Deletes all POIs from the map, memory and localStorage.
 * @param {Array} POIs - The array of POIs to delete (will be emptied).
 */
function deleteAllPOIs () {
   // Remove each marker from the map
   for (const poi of POIs) {
      if (poi._marker) map.removeLayer(poi._marker);
   }
   POIs.length = 0;    // Clear the array
   saveAppState();     // Update storage
   map.closePopup();   // Optional: close any open popup
}

/** 
 * Create string that will be shown in POI popup
 * @param {Object} poi - The poi.
 */
function button4POI (poi) {
   return `
      <span style="margin-right: 8px;"><strong>${poi.name}</strong></span>
      <button onclick="deletePOI('${poi.id}')" class="poi-delete-button" title="Delete this POI">🗑️</button>
   `;
}

/**
 * Show points of interest
 * @param {Array} POIs - List of POIs.
 */
function showPOI (POIs) {
   for (const poi of POIs) {
      const marker = L.marker([poi.lat, poi.lon]).addTo(map);
      poi._marker = marker;
      marker.bindPopup (button4POI (poi));
   }
}

/**
 * Deletes a POI by its index in the array.
 * Removes it from the map, from the array, and from localStorage.
 * @param {number} index - Index of the POI to delete
 */
function deletePOI(id) {
   const index = POIs.findIndex(poi => poi.id === id);
   if (index !== -1) {
      const poi = POIs[index];

      if (poi._marker) {
         map.removeLayer(poi._marker);
      }

      POIs.splice(index, 1);
      saveAppState();
      map.closePopup();
   }
}

/**
 * Extract subset of POI information (no marker)
 * useful for state management 
 */
function getSerializablePOIs () {
    return POIs.map(poi => ({
        id: poi.id,
        name: poi.name,
        lat: poi.lat,
        lon: poi.lon
        // no marker
    }));
}

/**
 * Adds a Point of Interest (POI) at the specified latitude and longitude.
 * This function prompts the user to enter a name for the POI using SweetAlert,
 * then adds it to the `POIs` list and places a marker on the map.
 *
 * @param {number} lat - Latitude of the POI.
 * @param {number} lon - Longitude of the POI.
 */
function addPOI(lat, lon) {
   Swal.fire({
      title: "POI",
      input: "text",
      inputPlaceholder: "Name",
      showCancelButton: true,
      confirmButtonText: "Add",
      cancelButtonText: "Cancel",
      inputValidator: (value) => {
         if (!value) return "Name cannot be empty";
      }
   }).then((result) => {
      if (result.isConfirmed) {
         const name = result.value;
         const id = crypto.randomUUID(); // ID unique (compatible navigateurs récents)

         const poi = { id, lat, lon, name };
         POIs.push(poi);
         saveAppState();

         const marker = L.marker([lat, lon])
            .addTo(map)
            .bindPopup (button4POI (poi))
            .openPopup();

         poi._marker = marker;
      }
   });

   closeContextMenu();
}

/**
 Loads competitors and polarName from localStorage, if available.
 Recreates dynamic Leaflet objects like markers and polylines. 
 */
function loadAppState() {
   const saved = localStorage.getItem ("competitors");
   const savedPolar = localStorage.getItem ("polarName");
   const savedPOIs = localStorage.getItem ("POIs");
   const savedWaypoints = localStorage.getItem ("myWayPoints");
   const savedMapMode = localStorage.getItem ("mapMode");
   const savedRouteParam = localStorage.getItem ("routeParam");
   if (saved) {
      const parsed = JSON.parse(saved);
      competitors = parsed.map(c => ({
         name: c.name,
         lat: c.lat,
         lon: c.lon,
         color: c.color,
         marker: {},
         routePolyline: []
     }));
   }
   if (savedPolar) polarName = savedPolar;
   if (savedPOIs) POIs = JSON.parse (savedPOIs);
   if (savedWaypoints) myWayPoints = JSON.parse(savedWaypoints);
   if (savedMapMode) mapMode = savedMapMode;
   //  if (savedRouteParam) routeParam = JSON.parse (savedRouteParam);
}

/**
 * Deletes a competitor from the global competitors array.
 * Removes their marker from the map and updates localStorage.
 * @param {Object} competitor - The competitor object to delete.
 */
function deleteCompetitor(competitor) {
   if (competitors.length <= 1) { // full table cannot be empty
      Swal.fire({
         icon: "error",
         title: "No way",
         text: "Forbidden to delete last boat.",
         confirmButtonText: "OK"
      });
      return;
   }
   const index = competitors.indexOf(competitor);
   if (index !== -1) {
      clearRoutes (); 
      // Supprimer le marker de la carte s’il existe
      if (competitor.marker && map.hasLayer(competitor.marker)) {
         map.removeLayer(competitor.marker);
      }

      // Supprimer du tableau
      competitors.splice(index, 1);
      orthoRouteGroup.clearLayers(); // clear waypoints on map
      for (let boat of competitors)  // redraw waypoints for remaining boats
         drawOrtho (boat, myWayPoints);

      // Mettre à jour le stockage
      saveAppState();
      updateBoatSelect();
   }
}

/**
 * Delete a competitor by name (used from HTML).
 * @param {string} name - Name of the competitor to delete.
 */
function deleteCompetitorByName(name) {
    const competitor = competitors.find(c => c.name === name);
    if (competitor) deleteCompetitor(competitor);
}

/** 
 * Create string that will be shown in boat (compertitor) popup
 * @param {Object} poi - The poi.
 */
function popup4Comp (competitor) {
   return `
      <span style="margin-right: 8px;"><strong>${competitor.name}</strong></span>
      <button onclick="deleteCompetitorByName('${competitor.name}')" class="poi-delete-button" title="Delete this Comp">🗑️</button>
   `;
}

/**
 * Draw grib limits
 * @param {Object} Grib limis
 * @returns {Array}. Bounds of grib
 */
function drawGribLimits (gribLimits) {
   if (!gribLimits || gribLimits.name === "") return;
   
   if (gribRectangle) {
      map.removeLayer(gribRectangle); // remove former rectangle if exists
   }

   let bounds = [
      [gribLimits.bottomLat, gribLimits.leftLon],
      [gribLimits.topLat, gribLimits.rightLon]
   ];

   gribRectangle = L.rectangle (bounds, {
      color: 'black',
      weight: 2,
      fillOpacity: 0 // only border
   }).addTo(map);

   map.invalidateSize(); // Force Leaflet to recompute drawings
   return bounds;
}

/**
 * Simplifies a list of coordinates by removing points that are too close to each other.
 * This uses a simple radial distance simplification method.
 *
 * @param {Array.<Array.<number>>} coords - Array of coordinates [latitude, longitude].
 * @param {number} tolerance - The distance tolerance for simplification (in degrees).
 * @returns {Array.<Array.<number>>} A simplified array of coordinates.
 */
function simplify (coords, tolerance) {
   if (coords.length <= 2) return coords;

   const sqTolerance = tolerance * tolerance;

   function getSqDist (p1, p2) {
      const dx = p1[1] - p2[1];
      const dy = p1[0] - p2[0];
      return dx * dx + dy * dy;
   }

   function simplifyRadialDist(points, sqTolerance) {
      let prevPoint = points[0];
      const newPoints = [prevPoint];
      let point;

      for (let i = 1, len = points.length; i < len; i++) {
         point = points[i];
         if (getSqDist(point, prevPoint) > sqTolerance) {
            newPoints.push(point);
            prevPoint = point;
         }
      }
      if (prevPoint !== point) newPoints.push(point);
      return newPoints;
   }
   return simplifyRadialDist(coords, sqTolerance);
}

/**
 * Draws a polyline on the map using Leaflet.
 *
 * @param {Array<Array<number>>} coords - An array of coordinates where each element is a tuple [latitude, longitude].
 * @param {string} color - The color of the polyline.
 */
function drawPolyline(coords, color, withPoints = false) {
   if (!coords || coords.length < 2) return;
   // 1. Original polyline 
   L.polyline(coords, {
      color: color,
      weight: 1,
      opacity: 0.8
      //dashArray: '4, 4'
   }).addTo(isochroneLayerGroup);
   if (withPoints) drawPoints (coords, color);

   /* 2. Simplification
   const simplifiedCoords = simplify(coords, 0.010);

   // 3. Draw simplified
   L.polyline(simplifiedCoords, {
      color: color,
      weight: 2,
      opacity: 0.8
   }).addTo(isochroneLayerGroup);*/
}

function drawPoints(coords, color) {
  if (!coords || coords.length === 0) return;

  coords.forEach(([lat, lng], i) => {
    L.circleMarker([lat, lng], {
      radius: 2,          // taille du point
      color: color,       // bord
      weight: 1,
      fillColor: color,   // remplissage
      fillOpacity: 0.9
    })
    .addTo(isochroneLayerGroup);
  });
}

function showTrace (map, trace) {
   if (! trace && trace.length <= 0) return;
   L.polyline(trace, {
      color: "orange",
      weight: 1,
      opacity: 0.8
   }).addTo(map);
   Swal.fire ("Trace done", `Lenght: ${trace.length}`, "success"); 
}

/** 
 * Show route in color associated to the competitor
 * @param {Object} The route
 * @param {string} Name of boat (idem competitor)
*/
function showRoute (route, name) {
   console.log (`Boat: ${name}`);

   const track = route [name].track;
   if (track.length === 0) return;
   // Convert format for Windy (Leaflet)
   const latlngs = track.map(coords => coords.slice(1, 3)); // lat, lon at pos 1 and 2
   //console.log (JSON.stringify (latlngs, null, 2));
   let iComp = competitors.findIndex (c => c.name === name); // index of current boat
   if (iComp < 0 ) iComp = 0;
   /*Si existe, l'ancienne  route change de couleur
   if (competitors[iComp].routePolyline) {
      window.oldRoutes = window.oldRoutes || [];
      window.oldRoutes.push(competitors[iComp].routePolyline); // Attention, copie par référence

      if (typeof competitors[iComp].routePolyline.setStyle === 'function') {
         competitors[iComp].routePolyline.setStyle({ color: 'pink' });
      } else {
         console.warn('routePolyline does not support setStyle');
      }
   }*/

   const routeColor = colorMap [competitors [iComp].color];
   if (competitors [iComp].routePolyline && typeof competitors [iComp].routePolyline.remove === 'function')
      competitors [iComp].routePolyline.remove ();
   competitors [iComp].routePolyline = L.polyline (latlngs, {color: routeColor}).addTo(map);
   competitors[iComp].marker.setLatLng ([competitors [iComp].lat, competitors [iComp].lon]); // Move the mark
   updateHeading (competitors [iComp], track);
   updateBindPopup (competitors [iComp]);
}

/**
 * Updates the Windy map by displaying the calculated route and optional isochrones.
 *
 * @param {Array<Array<number>>} route - The main sailing route as an array of coordinate pairs [latitude, longitude].
 * @param {Array<Array<number>>}  [isocArray=[]] - An optional array of isochrones, where each isochrone is a list of coordinate pairs.
 */
function updateWindyMap (route) {
   const boatName = Object.keys(route)[0]; // Extract first key from response
   if (!route || !route [boatName] || !route [boatName].track) {
      console.error ("Invalid data route !");
      return;
   }
   let isocArray = [];
   if ("_isoc" in route) isocArray = route ["_isoc"];
   
   isochroneLayerGroup.clearLayers ();
   const len = isocArray.length;

   if ((Array.isArray (isocArray)) && (moduloIsoc !== 0))
      for (let i = 0; i < len; i += 1) 
         if ((i % moduloIsoc) === 0) {
            const coords = isocArray[i].map(entry => [entry[0], entry[1]]);
            drawPolyline (coords, "blue", i >= len - 1);
         }

   let isoDescArray = [];
   isoDescMarkers.forEach(marker => map.removeLayer(marker));
   isoDescMarkers = [];
   const isoDescIcon = L.divIcon({
      className: 'iso-desc-icon',
      iconSize: [6, 6],     // taille réelle
      iconAnchor: [3, 3]    // centre sur la coordonnée
   });
   if (routeParam.isoDesc) {
      if ("_isodesc" in route) isoDescArray = route["_isodesc"];
      if (Array.isArray(isoDescArray)) {
         for (let i = 0; i < isoDescArray.length; i++) {
               const [nIsoc, wayPoint, size, first, closest, bestVmc, biggestOrthoVmc, focalLat, focalLon] = isoDescArray[i];
               const marker = L.marker([focalLat, focalLon], {icon: isoDescIcon})
                  .addTo(map)
                  .bindPopup(`nIsoc: ${nIsoc}<br>size: ${size}<br>closest: ${closest}<br>bestVmc: ${bestVmc}`);
               isoDescMarkers.push(marker);
         }
      }
   }

   competitors.forEach (refreshMarker);
   for (const [name, data] of Object.entries (route)) {
      if (! name.startsWith("_"))
         showRoute (route, name.trim());
   }
}

/**
 * Builds the request body for sending data to the server.
 * 
 * @param {string} reqType - The type of request.
 * @param {Array} competitors - List of competitor boats.
 * @param {Array} myWayPoints - List of waypoints as [latitude, longitude].
 * @param {Object} routeParam - Object containing route parameters.
 * @returns {string} The formatted request body.
 */
function buildBodyRequest (c, myWayPoints, routeParam) {
   const waypoints = myWayPoints
      .map(wp => `${wp[0]},${wp[1]}`)
      .join(";");
   console.log("boat: " + `${c.name},${c.lat},${c.lon}`);

   // Define request parameters with safer defaults
   const reqParams = {
      type: REQ.ROUTING,
      boat: `${c.name},${c.lat},${c.lon};`,
      waypoints: waypoints,
      timeStep: (routeParam.isoStep ?? 1800),
      epochStart: Math.floor(routeParam.startTime.getTime() / 1000),
      polar: `pol/${polarName}`,
      wavePolar: `wavepol/${polWaveName}`,
      forbid: (routeParam.forbid ?? "true"),
      isoc: (routeParam.isoc ?? "false"),
      isodesc: (routeParam.isoDesc ?? "false"),
      withWaves: (routeParam.withWaves ?? "false"),
      withCurrent: (routeParam.withCurrent ?? "false"),
      // timeInterval: (routeParam.timeInterval ?? 0), // useless for sever
      xWind: isNaN(routeParam.xWind ?? NaN) ? 1 : routeParam.xWind,
      maxWind: isNaN(routeParam.maxWind ?? NaN) ? 100 : routeParam.maxWind,
      penalty0: isNaN(routeParam.penalty0 ?? NaN) ? 0 : routeParam.penalty0,
      penalty1: isNaN(routeParam.penalty1 ?? NaN) ? 0 : routeParam.penalty1,
      penalty2: isNaN(routeParam.penalty2 ?? NaN) ? 0 : routeParam.penalty2,
      motorSpeed: isNaN(routeParam.motorSpeed ?? NaN) ? 0 : routeParam.motorSpeed,
      threshold: isNaN(routeParam.threshold ?? NaN) ? 0 : routeParam.threshold,
      dayEfficiency: isNaN(routeParam.dayEfficiency ?? NaN) ? 1.0 : routeParam.dayEfficiency,
      nightEfficiency: isNaN(routeParam.nightEfficiency ?? NaN) ? 1.0 : routeParam.nightEfficiency,
      initialAmure: isNaN(routeParam.initialAmure ?? NaN) ? 0 : routeParam.initialAmure,
      staminaVR: isNaN(routeParam.staminaVR ?? NaN) ? 100 : routeParam.staminaVR,
      cogStep: isNaN(routeParam.cogStep ?? NaN) ? 5 : routeParam.cogStep,
      cogRange: isNaN(routeParam.cogRange ?? NaN) ? 90 : routeParam.cogRange,
      jFactor: isNaN(routeParam.jFactor ?? NaN) ? 0 : routeParam.jFactor,
      kFactor: isNaN(routeParam.kFactor ?? NaN) ? 0 : routeParam.kFactor,
      nSectors: isNaN(routeParam.nSectors ?? NaN) ? 1 : routeParam.nSectors,
      model: routeParam.model,
      constWindTws: isNaN(routeParam.constWindTws ?? NaN) ? 0 : routeParam.constWindTws,
      constWindTwd: isNaN(routeParam.constWindTwd ?? NaN) ? 0 : routeParam.constWindTwd,
      constWave: isNaN(routeParam.constWave ?? NaN) ? 0 : routeParam.constWave,
      constCurrentS: isNaN(routeParam.constCurrentS ?? NaN) ? 0 : routeParam.constCurrentS,
      constCurrentD: isNaN(routeParam.constCurrentD ?? NaN) ? 0 : routeParam.constCurrentD
   };

   let requestBody = Object.entries(reqParams)
      .map(([key, value]) => `${key}=${value}`)
      .join("&");
   
   if (gribLimits.name && typeof gribLimits.name === "string" && gribLimits.name.trim().length > 1) {
      // requestBody += `&grib=grib/${gribLimits.name}`; // name of grib can be deduced by the sever thanks to model specification
      if (gribLimits.currentName.trim().length > 1)
         requestBody +=`&currentGrib=currentgrib/${gribLimits.currentName}`;
   }
   
   return requestBody;
}

/**
 show information around last point on map
 */
function showLastPointInfo(lastPointInfo) {
   lastPointLayer.clearLayers();
   let str;

   // Foot0
   let markFoot0 = L.marker([lastPointInfo.latFoot0, lastPointInfo.lonFoot0]);
   markFoot0.bindPopup(`Foot0, toDest: ${lastPointInfo.dFoot0}`).addTo(lastPointLayer);
   
   // Foot1
   let markFoot1 = L.marker([lastPointInfo.latFoot1, lastPointInfo.lonFoot1]);
   markFoot1.bindPopup(`Foot1, toDest: ${lastPointInfo.dFoot1}`).addTo(lastPointLayer);
   
   /* Dest
   let markDest = L.marker([lastPointInfo.latDest, lastPointInfo.lonDest]);
   markDest.bindPopup("Dest").addTo(lastPointLayer);*/
   
   // Curr
   str = `Curr, toDest: ${lastPointInfo.dCurr}`;
   let markCurr = L.marker([lastPointInfo.latCurr, lastPointInfo.lonCurr]);
   if ((lastPointInfo.latCurr === lastPointInfo.latFoot0) && (lastPointInfo.lonCurr === lastPointInfo.lonFoot0))
      str += " idem Foot0"  
   if ((lastPointInfo.latCurr === lastPointInfo.latFoot1) && (lastPointInfo.lonCurr === lastPointInfo.lonFoot1))
      str += " idem Foot1"  
   markCurr.bindPopup(str).addTo(lastPointLayer);

   // Prev
   str = `Prev, toDest: ${lastPointInfo.dPrev}, toCurr: ${lastPointInfo.dSeg0}`
   let markPrev = L.marker([lastPointInfo.latPrev, lastPointInfo.lonPrev]);
   if ((lastPointInfo.latPrev === lastPointInfo.latFoot0) && (lastPointInfo.lonPrev === lastPointInfo.lonFoot0))
      str += " idem Foot0"  
   if ((lastPointInfo.latPrev === lastPointInfo.latFoot1) && (lastPointInfo.lonPrev === lastPointInfo.lonFoot1))
      str += " idem Foot1"  
   markPrev.bindPopup(str).addTo(lastPointLayer);

   // Next
   str = `Next, toDest: ${lastPointInfo.dNext}, toCurr: ${lastPointInfo.dSeg1}`;
   let markNext = L.marker([lastPointInfo.latNext, lastPointInfo.lonNext]);
   if ((lastPointInfo.latNext === lastPointInfo.latFoot0) && (lastPointInfo.lonNext === lastPointInfo.lonFoot0))
      str += " idem Foot0"  
   if ((lastPointInfo.latNext === lastPointInfo.latFoot1) && (lastPointInfo.lonNext === lastPointInfo.lonFoot1))
      str += " idem Foot1"  
   markNext.bindPopup(str).addTo(lastPointLayer);
   
}

/**
 * Launch HTTP request using fetch, with the request body .
 *
 * @param {String} - requestBody - the request body.
 */
async function handleRequest (requestBody, showWarnings = true) {
   console.log ("handleRequest: " + requestBody);
   const headers = { "Content-Type": "application/x-www-form-urlencoded" };
   const token = btoa(`${userId}:${password}`);
   const auth = `Basic ${token}`;
   console.log ("token: " + token);
   local = apiUrl.includes ("localhost");
   if (auth && userId !== "" && !local) headers.Authorization = auth;     // else stay anonymous level 0
   //alert ("UserId: " + userId + " password: " + password);
   try {
      const response = await fetch (apiUrl, {
         method: "POST",
         headers,
         body: requestBody,
         cache: "no-store"
      });
      console.log (response);

      const data = await response.json();

      if ("_Error" in data) {
         console.log("ERROR RECEIVED =", data["_Error"]);
         await Swal.fire("Error from server", data["_Error"], "error");
         return null;
      }
      // console.log(JSON.stringify(data, null, 2));
      const firstKey = Object.keys(data)[0];

      if (showWarnings) {
         if ("_Warning_0" in data) await Swal.fire("Warning from server", data["_Warning_0"], "warning");
         if ("_Warning_1" in data) await Swal.fire("Warning from server", data["_Warning_1"], "warning");
         if ("_Warning_2" in data) await Swal.fire("Warning from server", data["_Warning_2"], "warning");
         const lastPointInfo = data [firstKey].lastPointInfo;
         if (lastPointInfo && routeParam.lastPointInfo) await showLastPointInfo (lastPointInfo);
      }

      return { boatName: firstKey, routeData: data };

   } catch (error) {
      console.error("Fetch error:", error);
      await Swal.fire({
         icon: "error",
         title: "Request Failed",
         text: "The server is not responding or returned an invalid response.",
         confirmButtonText: "OK"
      });
      return null;
   }
}

/**
 Check consistency between Grib metadatas and datas
 */
function consistentDataGrib (gribLimits, dataGrib) {
  if (!gribLimits || ! dataGrib || ! dataGrib.values) return false;
  const { nTimeStamp, nLat, nLon } = gribLimits;
  const len0 =  nTimeStamp * nLat * nLon * 4; // u v g w
  const len1 = dataGrib.values.length;
  return (len0 > 0) && (len0 === len1);
}

/**
 * Update display after getting server response.
 *
 * @param {String} - boatName - the name of boat or competitor.
 */
async function finalUpdate (boatName) {
   const iComp = competitors.findIndex (c => c.name === boatName); // index of current boat
   goBegin ();
   updateWindyMap (route);

   if ((gribLimits.name === route [boatName].grib) && (consistentDataGrib (gribLimits, dataGrib))) {
      updateStatusBar (route);
      return;
   }

   gribLimits.bottomLat = route [boatName]?.bottomLat || gribLimits.bottomLat;
   gribLimits.leftLon = route [boatName]?.leftLon || gribLimits.leftLon;
   gribLimits.topLat = route [boatName]?.topLat || gribLimits.topLat;
   gribLimits.rightLon = route [boatName]?.rightLon || gribLimits.rightLon;
   gribLimits.name = route [boatName]?.grib || gribLimits.name;

   if (mapMode != MAP_MODE.WINDY && barbDisp) {
      try {
         updateStatusBar (route, "Updating Grib");
         await gribMetaAndLoad("grib", "", gribLimits.name, true); // load if not windy
         // IMPORTANT : let browser breathe after Swal.close()
         await new Promise(r => setTimeout(r, 0));
         drawWind ();
      } catch (err) {
         console.error("Error in gribMetaAndLoad:", err);
      }
   }
   
   drawGribLimits (gribLimits);
   updateStatusBar (route);
}

/**
 * Creates and displays a centered progress bar on the screen.
 *
 * The progress bar is styled to be centered with a gray background and an orange fill.
 * It automatically injects CSS styles the first time it is called.
 *
 * @param {number} maxValue - The maximum value for the progress bar (i.e., the total number of steps).
 * @returns {HTMLProgressElement} The created and appended progress element.
 */
function createProgressBar(maxValue) {
    // Create container
    let container = document.createElement('div');
    container.style.position = 'fixed';
    container.style.top = '50%';
    container.style.left = '50%';
    container.style.transform = 'translate(-50%, -50%)';
    container.style.width = '300px';
    container.style.height = '30px';
    container.style.zIndex = '500';
    container.style.backgroundColor = '#eee';
    container.style.border = '1px solid #ccc';
    container.style.borderRadius = '5px';
    container.style.overflow = 'hidden';
    container.style.fontFamily = 'Arial, sans-serif';
    container.style.textAlign = 'center';
    container.style.lineHeight = '30px'; // center text vertically
    container.style.color = '#333';
    container.style.fontWeight = 'bold';
    container.style.fontSize = '16px';
    container.style.userSelect = 'none'; // avoid text selection

    // Create a progress inner div
    let progressInner = document.createElement('div');
    progressInner.style.height = '100%';
    progressInner.style.width = '0%';
    progressInner.style.backgroundColor = 'orange';
    progressInner.style.borderRadius = '5px 0 0 5px';
    progressInner.style.transition = 'width 0.3s'; // smooth animation

    // Create a percentage text
    let percentage = document.createElement('div');
    percentage.textContent = '0%';
    percentage.style.position = 'absolute';
    percentage.style.top = '50%';
    percentage.style.left = '50%';
    percentage.style.transform = 'translate(-50%, -50%)';
    percentage.style.pointerEvents = 'none'; // clicks go through

    // Add to DOM
    container.appendChild(progressInner);
    container.appendChild(percentage);
    document.body.appendChild(container);

    // Function to update progress
    container.update = function(value) {
        let percent = Math.min(100, Math.round((value / maxValue) * 100));
        progressInner.style.width = percent + '%';
        percentage.textContent = percent + '%';
    };

    return container;
}

/**
 * Constructs and prepares the request body for a routing API call.
 *
 * Request for only one boat, at different time defined by routeParam.
 */
async function requestBestTime () {
   initialStartTime = routeParam.startTime; // global
   bestStartTime = routeParam.startTime;     // global
   let result = [];
   let bestDuration = Infinity;
   let progress = createProgressBar(routeParam.nTry);
   let boatName, routeData;

   await new Promise(resolve => setTimeout(resolve, 0));
   compResult.length = 0; // inhibate display of competitor dashboard
   for (let i = 0; i < routeParam.nTry; i++) {
      routeParam.startTime = new Date(initialStartTime.getTime() + i * routeParam.timeInterval * 1000);
      let requestBody = buildBodyRequest (competitors[routeParam.iBoat - 1], myWayPoints, routeParam);

      if (clipBoard && i === 0) {
         navigator.clipboard.writeText(requestBody);
      }

      const response = await handleRequest (requestBody);

      if (response) {
         ({ boatName, routeData } = response);
         if (! routeData [boatName].destinationReached)  // stop when first unreached found
            break;
         let duration = routeData [boatName].duration;
         result.push (duration);
         if (duration < bestDuration) {
            bestDuration = duration;
            route = routeData; // keep best solution in global variable
            bestStartTime = routeParam.startTime;
         }
        // alert('i = ' + i + ' epochStart = ' + routeParam.startTime + ' boatName: ' + boatName + ' duration = ' + duration);
      } else {
         console.error('Request failed at i =', i);
      }
      progress.update (i + 1);
   }
   
   console.log ("All durations:", result);
   progress.remove ();
   bestTimeResult = result;
   dispBestTimeHistogram (bestTimeResult, initialStartTime, bestStartTime, routeParam.timeInterval);
   routeParam.startTime = bestStartTime;
   
   if (result.length !== 0) finalUpdate (boatName);
}

/**
 * Constructs and prepares the request body for a routing API call.
 *
 * Request for all boats, at startTime.
 */
async function requestAllCompetitors () {
   let saveIBoat = routeParam.iBoat;
   let result = [];
   let progress = createProgressBar (competitors.length);
   await new Promise(resolve => setTimeout(resolve, 0));
   let lastBoatName;
   route = {};
   let OK = false;
   const saveIsoc = routeParam.isoc;
   routeParam.isoc = false; // force no Isochrones if all competitors

   for (let i = 0; i < competitors.length; i++) {
      routeParam.iBoat = i + 1;
      let requestBody = buildBodyRequest (competitors[i], myWayPoints, routeParam);

      if (clipBoard && i === 0) {
         navigator.clipboard.writeText(requestBody);
      }

      const response = await handleRequest (requestBody);

      if (response) {
         const { boatName, routeData } = response;
         if (! routeData [boatName].destinationReached)  // stop when first unreached found
            result.push (-1);
         else {
            let duration = routeData [boatName].duration;
            result.push (duration);
            Object.assign (route, routeData);            // add the new route found
            if (! boatName.startsWith("_"))
               showRoute (routeData, boatName.trim());
            lastBoatName = boatName;
            OK = true;
         }
      } else {
         console.error('Request failed at i =', i);
         OK = false;
         break;
      }
      progress.update (i + 1);
   }

   console.log ("All durations:", result);
   routeParam.iBoat = saveIBoat;
   dispAllCompetitors (result);
   progress.remove ();
   compResult = result; // saved in global variable
   routeParam.isoc = saveIsoc;
   if (OK) finalUpdate (lastBoatName);
}

/**
 * Constructs and prepares the request body for two outing API call.
 * First is an estimation with big time step
 * Second is with timestep  as requested by user
 *
 * Request for only one boat, one time
 */
async function requestTwo () {
   const BIG_TIME_STEP = 10800; // 3 hours
   const spinnerOverlay = document.getElementById ("spinnerOverlay");
   let duration;
   let OK0 = false;
   let OK1 = false;
   spinnerOverlay.style.display = "flex"; // display spinner
   compResult.length = 0; // inhibate display of competitor dashboard
   const savedIsoStep = routeParam.isoStep;
   routeParam.isoStep = BIG_TIME_STEP;

   let requestBody = buildBodyRequest (competitors[routeParam.iBoat - 1], myWayPoints, routeParam);
   if (clipBoard) navigator.clipboard.writeText (requestBody);
   
   let response = await handleRequest (requestBody, false);
   if (response) {
      const { boatName, routeData } = response;
      console.log ("requestTwo 1/2:" + JSON.stringify(routeData, null, 2));
      route = routeData; // global variable
      // showRouteReport (routeData);  
      // finalUpdate (boatName);
      OK0 = routeData[boatName].destinationReached;
      duration = routeData[boatName].duration;
   } else {
      console.error('Request failed');
      spinnerOverlay.style.display = "none";
      return;
   }
   
   routeParam.isoStep = savedIsoStep; // idem with requested timestep
   if (adjustIsoStep (routeParam, duration)) {
      const mn = routeParam.isoStep / 60;
      await Swal.fire ("Time Step Modified", 
                 `The time step has been set to higher value: ${mn} mn`, 
                 "Info"); 
   }

   requestBody = buildBodyRequest (competitors[routeParam.iBoat - 1], myWayPoints, routeParam);
   if (clipBoard) navigator.clipboard.writeText (requestBody);
   response = await handleRequest (requestBody);
   if (response) {
      ({ boatName, routeData } = response);
      console.log ("requestTwo 2/2:" + JSON.stringify(routeData, null, 2));
      route = routeData; // global variable
      OK1 = routeData[boatName].destinationReached;
   } else {
      spinnerOverlay.style.display = "none";
      console.error('Request failed');
      OK1 = false;
   }
   spinnerOverlay.style.display = "none";
   if (OK0 && !OK1) {
      await Swal.fire({
         icon: "error",
         title: "Reachable but failed with proposed timeStep",
         text: "Try lower timestep",
         confirmButtonText: "OK"
      });
   }
   showRouteReport (routeData);  
   finalUpdate (boatName);
}

/**
 * Constructs and prepares the request body for a routing API call.
 *
 * Request for anly one boat, one time
 */
async function requestOne () {
   const spinnerOverlay = document.getElementById ("spinnerOverlay");
   spinnerOverlay.style.display = "flex"; // display spinner
   compResult.length = 0; // inhibate display of competitor dashboard
   let requestBody = buildBodyRequest (competitors[routeParam.iBoat - 1], myWayPoints, routeParam);

   if (clipBoard) navigator.clipboard.writeText (requestBody);
   const response = await handleRequest (requestBody);
   if (response) {
      const { boatName, routeData } = response;
      console.log ("requestOne:" + JSON.stringify(routeData, null, 2));
      route = routeData; // global variable
      showRouteReport (routeData);  
      finalUpdate (boatName);
   } else {
      console.error('Request failed');
   }
   spinnerOverlay.style.display = "none";
}

/**
 * Prepare routing API call.
 *
 * Depending on routeParameter, select between requestBestTime, requestAllCompetitors or requestOne
 */
function request () {
   if (myWayPoints === null || myWayPoints === undefined || myWayPoints.length === 0) {
      Swal.fire({
         icon: "error",
         title: "Invalid Request",
         text: "No waypoints or destination defined.",
         confirmButtonText: "OK"
      });
      return;
   }
   clearRoutes ();

   if (routeParam.nTry > 1 && routeParam.timeInterval !== 0) {
      requestBestTime ();
      return;
   }
   if (routeParam.iBoat === 0) { // iboat == 0 means all competitors
      requestAllCompetitors ();
      return;
   }
   requestTwo ();
}

/**
 * Replay the routing request:
 * 1. Displays an empty input box to paste a query manually.
 * 2. Parses the input into `routeParam`, `myWayPoints`, and `competitors`.
 * 3. Triggers the `request()` function.
 */
async function replay () {
   try {
      // Show input dialog (empty by default)
      const { value: queryString } = await Swal.fire({
         title: 'Replay routing request',
         input: 'textarea',
         inputLabel: 'Paste routing query manually',
         inputValue: '',
         inputAttributes: {
            'aria-label': 'Routing query input'
         },
         showCancelButton: true,
         confirmButtonText: 'Replay',
         cancelButtonText: 'Cancel',
         width: '60em'
      });

      if (!queryString) return;

      // Parse query string to key-value pairs
      const parseQueryString = qs => {
         const out = {};
         qs.split('&').forEach(pair => {
            const [rawK, ...rawV] = pair.split('=');
            const k = decodeURIComponent(rawK);
            const v = decodeURIComponent(rawV.join('=')); // au cas où '=' dans la valeur
            out[k] = v;
         });
         return out;
      }

      const raw = parseQueryString(queryString);

      // waypoints = "lat,lon;lat,lon;..."
      const parseWaypoints = str => str.split(';').map(s => {
         const [lat, lon] = s.split(',').map(Number);
         return [lat, lon];
      });

      // boat(s) = "name, lat, lon;name, lat, lon;..."
      const parseCompetitors = str => {
         return str
            .split(';')
            .map(s => s.trim())
            .filter(s => s.length > 0)
            .map((entry, idx) => {
               const [name, lat, lon] = entry
                  .split(',')
                  .map((v, i) => (i === 0 ? v.trim() : Number(v)));
               return {name, lat, lon, color: idx};
            });
      };

      const toBool = v => String(v).toLowerCase() === "true"; // "true" -> true, all rest -> false

      routeParam = {
         iBoat: Number(raw.type) || 1,                           // "type"
         isoStep: Number(raw.timeStep) || 900,                   // "timeStep"
         nTry: 0,
         timeInterval: 0,                                        // useless for server
         epochStart: Number(raw.epochStart) || 0,
         startTime: new Date(Number(raw.epochStart) * 1000),
         polar: raw.polar || "",                                 // ex: "pol/first260.pol"
         wavePolar: raw.wavePolar || "",                                 // ex: "pol/first260.pol"
         forbid: toBool(raw.forbid),
         isoc: toBool(raw.isoc),
         isoDesc: toBool(raw.isodesc),
         withWaves: toBool(raw.withWaves),
         withCurrent: toBool(raw.withCurrent),
         xWind: Number(raw.xWind),
         maxWind: Number(raw.maxWind),
         penalty0: Number(raw.penalty0),
         penalty1: Number(raw.penalty1),
         penalty2: Number(raw.penalty2),
         motorSpeed: Number(raw.motorSpeed),
         threshold: Number(raw.threshold),
         dayEfficiency: Number(raw.dayEfficiency),
         nightEfficiency: Number(raw.nightEfficiency),
         initialAmure: Number(raw.initialAmure),
         cogStep: Number(raw.cogStep),
         cogRange: Number(raw.cogRange),
         jFactor: Number(raw.jFactor),
         kFactor: Number(raw.kFactor),
         nSectors: Number(raw.nSectors),
         model: raw.model || "GFS",
         staminaVR: Number(raw.staminaVR),
         constWindTws: Number(raw.constWindTws),
         constWindTwd: Number(raw.constWindTwd),
         constWave: Number(raw.constWave),
         constCurrentS: Number(raw.constCurrentS),
         constCurrentD: Number(raw.constCurrentD)
      };

      myWayPoints = parseWaypoints(raw.waypoints); // global variable
      competitors = parseCompetitors(raw.boat);    // global variabe

      console.log("routeParam =", routeParam);
      //console.log("wayPoints =", wayPoints);
      console.log("competitors =", competitors);
      polarName = routeParam.polar.split('/')[1];  // global variable
      polWaveName =  routeParam.wavePolar.split('/')[1]; // global variable
      for (let competitor of competitors) {
         if (competitor.marker)
            competitor.marker.remove ();
      }
      for (let competitor of competitors) {
         addMarker (competitor);
         setBoat (competitor, competitor.lat, competitor.lon);
      }
      showWayPoint (myWayPoints);
      // Trigger routing request
      request ();
   } catch (error) {
      console.error('Replay failed:', error);
      Swal.fire('Error', 'Unable to parse the input or start the route.', 'error');
   }
}

/**
 * Computes the great-circle path (orthodromic route) between two geographical points.
 *
 * This function calculates a series of intermediate points along the shortest path
 * between two locations on the Earth's surface, using spherical interpolation.
 *
 * @param {number} lat0 - Latitude of the starting point, in degrees.
 * @param {number} lon0 - Longitude of the starting point, in degrees.
 * @param {number} lat1 - Latitude of the destination point, in degrees.
 * @param {number} lon1 - Longitude of the destination point, in degrees.
 * @param {number} [n=100] - Number of intermediate points to compute along the path.
 * @returns {Array<Array<number>>}  The great-circle path as an array of coordinate pairs [latitude, longitude].
 */
function getGreatCirclePath(lat0, lon0, lat1, lon1, n = 100) {
   const epsilon = 0.001;
   const path = [];

   lat0 *= DEG_TO_RAD, lon0 *= DEG_TO_RAD, lat1 *= DEG_TO_RAD, lon1 *= DEG_TO_RAD; // deg to radius conversion

   // distance angulaire centrale
   const d = Math.acos(
      Math.sin(lat0) * Math.sin(lat1) +
      Math.cos(lat0) * Math.cos(lat1) * Math.cos(lon1 - lon0)
   );

   // si les deux points sont (presque) identiques
   if ((Math.abs(lat1 - lat0) < epsilon) && (Math.abs(lon1 - lon0) < epsilon)) {
      return [[lat0 * RAD_TO_DEG, lon0 * RAD_TO_DEG], [lat1 * RAD_TO_DEG, lon1 * RAD_TO_DEG]];
   }

   for (let i = 0; i <= n; i++) {
      const f = i / n;

      // slerp le long du grand cercle
      const A = Math.sin((1 - f) * d) / Math.sin(d);
      const B = Math.sin(f * d) / Math.sin(d);

      const x = A * Math.cos(lat0) * Math.cos(lon0) + B * Math.cos(lat1) * Math.cos(lon1);
      const y = A * Math.cos(lat0) * Math.sin(lon0) + B * Math.cos(lat1) * Math.sin(lon1);
      const z = A * Math.sin(lat0) + B * Math.sin(lat1);

      const lat = Math.atan2(z, Math.sqrt(x * x + y * y));
      const lon = Math.atan2(y, x);

      path.push([lat * RAD_TO_DEG, lon * RAD_TO_DEG]); // rad -> deg
   }

   return path;
}

/**
   calculate distance from boat to destination through waypoints
*/
function totalDistWayPoints (competitor, myWayPoints) {
   if (!competitor || ! myWayPoints || myWayPoints.length === 0) return 0;
   let lat0 = competitor.lat, lon0 = competitor.lon;
   let dist = 0;
   myWayPoints.forEach ((wp, index) => {
      dist += orthoDist (lat0, lon0, wp[0], wp [1]);
      lat0 =  wp[0], lon0 = wp [1];
   });
   return dist;
}

/**
 * Draws both theorthodromic (great-circle) routes 
 * for a given competitor and waypoints.
 *
 * This function extracts the competitor's starting position, and prepares the orthodromic route.
 *
 * @param {Object} competitors - competitor is an object containing at least name, lat, lon.
 * @param {Array<Array<number>>} myWayPoints - Array of waypoints as [latitude, longitude] pairs.
 */
function drawOrtho (competitor, myWayPoints) {
   if (!competitor) {
      console.error("Invalid data for competitor.");
      return;
   }
   // extract lat and lon of specified competitor
   let firstPoint = [competitor.lat, competitor.lon];

   // build table of points 
   let routePoints = [firstPoint, ...myWayPoints];
   console.log ("routePoints:", routePoints);
   console.log ("routePoints:", routePoints);

   //orthoRouteGroup.clearLayers();
   // Create orthodromie
   routePoints.forEach ((wp, index) => {
      if (index < routePoints.length - 1) {
         let lat0 = wp [0], lon0 = wp [1];
         let lat1 = routePoints [index + 1][0], lon1 = routePoints[index + 1][1];

         let path = getGreatCirclePath (lat0, lon0, lat1, lon1, 100);
         let polyline = L.polyline (path, { color: 'green', weight: 3, dashArray: '5,5' });

         // Ajoute la polyligne au groupe au lieu de l'écraser
         orthoRouteGroup.addLayer (polyline);
      }
   });
}

/**
 * Clears all routes, waypoints, and markers from the map.
 * This function resets the waypoints list, removes all layers and polylines,
 * and clears markers for the destination and computed routes.
 */
function clearRoutes () {
   isochroneLayerGroup.clearLayers();
   // Delete old routes
   //window.oldRoutes.forEach(route => route.remove());
   //window.oldRoutes = [];
   competitors.forEach(comp => {
      if (comp.routePolyline && typeof comp.routePolyline.remove === 'function') {
         comp.routePolyline.remove();
        // comp.routePolyline = null;
      }
   });
   route = null;
   index = 0;
   competitors.forEach (refreshMarker);
   updateStatusBar (route);
}

/**
 * Resets all waypoints and clears related map elements.
 * This function removes all stored waypoints, clears the loxodromic 
 * and orthodromic routes, and removes the destination marker if it exists.
 */
function resetWaypoint () {
   myWayPoints = [];
   orthoRouteGroup.clearLayers();
   if (destination) destination.remove ();
   saveAppState();
}

/**
 * Updates the boat's position to the specified latitude and longitude.
 * This function moves the selected competitor to the given coordinates,
 * updates its map marker, and redraws the orthodromic route.
 * If waypoints exist, it also updates the boat's heading.
 *
 * @param {Object} competitor is an object containing at least name, latitude, longitude.
 * @param {number} lat - New latitude of the boat.
 * @param {number} lon - New longitude of the boat.
 */
function setBoat (competitor, lat, lon) {
   competitor.lat = lat;
   competitor.lon = lon;

   competitor.marker.setLatLng ([lat, lon]);                   // Move the mark

   console.log("Waypoints:", myWayPoints);
   drawOrtho (competitor, myWayPoints);

   if (myWayPoints.length > 0) {
      let heading = orthoCap(lat, lon, myWayPoints[0][0], myWayPoints [0][1]);
      competitor.marker._icon.setAttribute ('data-heading', heading);
      updateIconStyle (competitor.marker);
   }
   closeContextMenu();
   orthoRouteGroup.clearLayers();
   for (let boat of competitors)
      drawOrtho (boat, myWayPoints);
   saveAppState ();
}

/**
 * Add destination at the specified latitude and longitude.
 *
 * @param {number} lat - Latitude of the destination.
 * @param {number} lon - Longitude of the destination.
 */
function showDestination (lat, lon) {
   if (destination) destination.remove();
   destination = L.marker([lat, lon], {
      icon: L.divIcon({
         className: 'custom-destination-icon',
         html: '🏁',
         iconSize: [32, 32],
         iconAnchor: [8, 16]
      })
   }).addTo(map);
}

/**
 * Adds a new waypoint at the specified latitude and longitude.
 * This function appends a new waypoint to `myWayPoints`. If it is the first waypoint, 
 * it also updates the boat's heading toward this point.
 *
 * @param {number} lat - Latitude of the new waypoint.
 * @param {number} lon - Longitude of the new waypoint.
 */
function addWaypoint (lat, lon) {
   if (myWayPoints.length === 0) {
      for (let boat of competitors) {
         // position heading of boat at first waypoint
         let heading = orthoCap(boat.lat, boat.lon, lat, lon);
         boat.marker.setLatLng ([boat.lat, boat.lon]);                   // Move the mark
         boat.marker._icon.setAttribute('data-heading', heading); 
         updateIconStyle (boat.marker);
      }
   }

   myWayPoints.push ([lat, lon]);
   for (let boat of competitors)
      drawOrtho (boat, myWayPoints);
   showDestination (lat, lon);
   console.log ("Waypoints:", myWayPoints);
   closeContextMenu();
   saveAppState();
   const dist = totalDistWayPoints (competitors [0], myWayPoints)
   const name = competitors [0].name;
   Swal.fire ({
      toast: true,
      title: `From: ${name}: ${dist.toFixed (2)} nm`,
      timer: 1500,
      //width: '200px',
      showConfirmButton: false,
      timerProgressBar: true
   });
}

/**
 * Updates the icon rotation based on the boat's heading.
 * This function checks if a marker exists and retrieves its heading attribute.
 * If the icon does not already have a rotation applied, it modifies the CSS `transform` property 
 * to rotate the marker according to its heading.
 */
function updateIconStyle (marker) {
   if (!marker) return;
   const icon = marker._icon;
   if (!icon) return;
   const heading = marker._icon.getAttribute('data-heading');
   if (icon.style.transform.indexOf ('rotateZ') === -1) {
      icon.style.transform = `${
         icon.style.transform
      } rotateZ(${heading}deg)`;
      icon.style.transformOrigin = 'center';
   }
}

/**
 * Moves the boat along its track by a given step.
 * This function updates the boat's position based on the given track and step count.
 * It adjusts the index within valid bounds, updates the boat's marker position, 
 * and updates the Windy map timestamp accordingly.
 *
 * @param {Array<Array<number>>} firstTrack - The array of waypoints, where each waypoint is [latitude, longitude].
 * @param {number} n - The step count to move forward (positive) or backward (negative).
 */
function move (iComp, firstTrack, index) {
   if (firstTrack.length === 0 || index >= firstTrack.length) {
      return;
   }
   if (index < 0) {
      index = Math.max (firstTrack.length - 1, 0);
   }
   let time = getDateFromIndex (index, competitors [iComp].name );
   let newLatLng = firstTrack[index].slice(1, 3);     // New position
   console.log ("move: ", iComp, "time: " + time);
   competitors [iComp].marker.setLatLng (newLatLng);  // Move the mark

   // Center map on new boat position
   // map.setView(newLatLng, map.getZoom());
   if (mapMode === MAP_MODE.WINDY) store.set ('timestamp', time);                     // update Windy time
   updateHeading (competitors [iComp], firstTrack);
   updateStatusBar (route); 
}

function arrowEmojiFromAngle(deg) {
   const index = Math.round(deg / 45) % 8;                 // 8 secteurs
   const arrows = ["⬇️","↙️","⬅️","↖️", "⬆️","↗️","➡️","↘️"];
   return arrows[index];
}

/**
 * Update bind popup
 */
function updateBindPopup (competitor) {
   const epsilon = 0.1;
   let [wp, lat, lon, time, dist, sog, twd, tws, hdg, twa, g, w, stamina, sail, motor] = route [competitor.name].track [index];
   hdg = (360 + hdg) % 360;
   const propulse = (motor ? "Motor": "Sail: " + sail) ?? "-";
   const theDate = new Date (routeParam.startTime.getTime() + time * 1000);
   const directDist = orthoDist(competitor.lat, competitor.lon, lat, lon);
   const avrHdg = (directDist > epsilon) ? orthoCap(competitor.lat, competitor.lon, lat, lon) : 0.0; 

   // alert ("updateBindPopup name: " + competitor.name);
   if (sog !== undefined) {
      const twdArrow = arrowEmojiFromAngle(twd);
      competitor.marker.bindPopup(`
         ${popup4Comp(competitor)}<br>
         ${dateToStr(theDate)}<br>
         ${latLonToStr(lat, lon, DMSType)}<br>
         Twd: ${twd.toFixed(0)}°  ${twdArrow} Tws: ${tws.toFixed(2)} kn<br>
         Hdg: ${hdg.toFixed(0)}° 
         Twa: <span style="color:${(twa >= 0) ? 'green' : 'red'}; font-weight:bold;">${twa.toFixed(0)}°</span><br>
         Sog: ${sog.toFixed(2)} kn ${propulse}<br><br>
         Average Hdg: ${avrHdg.toFixed(0)}° Direct Distance: ${directDist.toFixed(2)} NM<br>
      `);
   }
}

/**
 * Updates the boat's heading based on its current and next position.
 * This function calculates the bearing (heading) between the current position and 
 * the next point in the track, and updates the boat's marker rotation.
 *
 * @param {Array<Array<number>>} firstTrack - The array of waypoints, where each waypoint is [latitude, longitude].
 */
function updateHeading (competitor, firstTrack) {
   if (!firstTrack || firstTrack.length === 0) return;
   let newIndex = (index < firstTrack.length - 1) ? index : index - 1;
   if (newIndex <= 0) newIndex = 0;
   let [, , , , , , , , hdg, , , , , , ] = firstTrack [newIndex];
   competitor.marker._icon.setAttribute('data-heading', hdg);
   updateIconStyle (competitor.marker);
}

/**
 * Common part of goBegin, backWard and forWard.
 * This function calls move for all relevant competitors 
 */
function updateAllBoats () {
   if (!route) return;
   if (mapMode !== MAP_MODE.WINDY && barbDisp) drawWind ();
   const boatNames = Object.keys(route);
   console.log ("boatNames:", boatNames);
   boatNames.forEach((name, i) => {
      let iComp = competitors.findIndex (c => c.name === name); // index of current boat
      if ((iComp >= 0) && (! name.startsWith("_"))) {
         console.log ("boatName: ", name, "iComp = ", iComp);
         move (iComp, route[name].track, index);
         updateBindPopup (competitors [iComp]);
      }
   });
}

/**
 * Resets the boat's position to the beginning of its track.
 * This function sets the index to 0 and moves the boat to the first waypoint,
 * ensuring time and position are updated accordingly.
 */
function goBegin () {
   index = 0;
   updateAllBoats ();
   stopAnim ();
}

/**
 * Moves the boat one step backward along its track.
 * Calls `move()` with a negative step value to shift the boat to the previous waypoint.
 */
function backWard () {
   index -= 1;
   if (index < 0) index = 0;
   updateAllBoats ();
   stopAnim ();
}

/**
 * Moves the boat continuously along its track.
 */
function playAnim() {
   if (animation) return;                    // avoid double animation
   const boatName = Object.keys(route)[0];   // Extract first key from response
   const len = route[boatName].track.length;
   const icon = document.getElementById('playPauseIcon');
   icon.classList.remove('fa-play');
   icon.classList.add('fa-pause');
   animation = setInterval(() => {
      if (index >= len) {
         stopAnim();
         return;
      }
      // if (index >= len || index < 0) index = 0;
      index += 1;
      updateAllBoats ();
   }, 500); // Intervalle de mise à jour en ms
}

/**
 * Stops boatsboat 
 */
function stopAnim() {
    const icon = document.getElementById('playPauseIcon');
    icon.classList.remove('fa-pause');
    icon.classList.add('fa-play');
    clearInterval(animation);
    animation = false;
}

/**
/**
 * Moves the boat one step forward along its track.
 * Calls `move()` with a positive step value to shift the boat to the next waypoint.
 */
function forWard () {
   index += 1;
   const boatName = Object.keys(route)[0]; // Extract first key from response
   const len = route[boatName].track.length;
   if (index > len) index = len - 1;
   updateAllBoats ();
   stopAnim ();
}

/**
 * Moves the boat to tthe end its track.
 * Calls `move()` with final index to mùove the boat the the end of track.
 */
function goEnd () {
   const boatName = Object.keys(route)[0];  // Extract first key from response
   index = route[boatName].track.length - 1; // the end of the main boat track
   console.log ("in goEnd, last:" + index);
   updateAllBoats ();
   stopAnim ();
}

/**
 * Formats a date into a human-readable string with the weekday, date, and local time.
 * Example output: "Saturday, 2025-03-08 22:44 Local Time"
 *
 * @param {Date} date - The date object to format.
 * @returns {string} The formatted date string.
 */
function formatLocalDate (date) {
    // Get individual date components
   const dayName = date.toLocaleString('en-US', { weekday: 'short' });
   const year = date.getFullYear();
   const month = String(date.getMonth() + 1).padStart(2, '0'); // Ensure two-digit format
   const day = String(date.getDate()).padStart(2, '0');
   const hours = String(date.getHours()).padStart(2, '0');
   const minutes = String(date.getMinutes()).padStart(2, '0');

   return `${dayName}, ${year}-${month}-${day} ${hours}:${minutes} Local Time`;
}

/**
 * Updates status bar information and calculates bounds.
 * If no route is provided, it updates general parameters such as the polar name and GRIB file.
 *
 * @param {Object|null} [route=null] - The route object containing navigation data, or `null` to update global info.
 */
function updateStatusBar (route = null, warning = null) {
   updateToolsVisibility();
   setTimelineVisible(! route);
   let time = " "; // important to keep space
   let polar = "", wavePolar = "", grib = "", currentGrib = "";
   if (route === null) {
      polar = polarName;
      wavePolar = polWaveName;
      grib = gribLimits.name;
      currentGrib = gribLimits.currentName;
   }
   else if (route) {
      const boatName = Object.keys(route)[0]; // Extract first key from response
      polar = route[boatName].polar;
      wavePolar = route[boatName].wavePolar;
      grib = route[boatName].grib;
      currentGrib = route[boatName].currentGrib;
      time = " 📅 " + formatLocalDate (getDateFromIndex (index, boatName));
      const p =  Math.round((index * 100) / (route[boatName].track.length -1));
      document.getElementById("timeLine").value = String(p);
      document.getElementById("timeLineValue").textContent = String(p).padStart(3, '0') + "%";
   }
   document.getElementById("infoRoute").innerHTML = "";
   if (time.length > 0) document.getElementById("infoTime").innerHTML = time; 
   if (polar.length > 0) document.getElementById("infoRoute").innerHTML += "    ⛵ polar: " + polar; 
   if (wavePolar.length > 0) document.getElementById("infoRoute").innerHTML += "    🌊 wavePolar: " + wavePolar;
   if (grib.length > 0) document.getElementById("infoRoute").innerHTML += "    💨 Grib: " + grib;
   if (currentGrib.length > 0) document.getElementById("infoRoute").innerHTML += "    🔄 currentGrib: " + currentGrib;
   if (warning) document.getElementById("infoRoute").innerHTML +=` <span class="warning-blink"> ⚠️ ${warning}</span>`;
}

/**
 * Determines the bounding box for a given set of waypoints.
 *
 * This function calculates the minimum and maximum latitude/longitude values 
 * to define the bounding box that encloses the route.
 *
 * @param {Array<Array<number>>} wayPoints - An array of waypoints as [latitude, longitude] pairs.
 */
function findBounds (wayPoints) {
   if (wayPoints.length < 2) return;
   let lat0 = wayPoints [0][0];
   let lat1 = wayPoints [wayPoints.length - 1][0];
   let lon0 = wayPoints [0][1];
   let lon1 = wayPoints [wayPoints.length - 1][1];
   
   bounds = [
      [Math.floor (Math.min (lat0, lat1)), Math.floor (Math.min (lon0, lon1))],  // Inf left
      [Math.ceil (Math.max (lat0, lat1)), Math.ceil (Math.max (lon0, lon1))]     // Sup right
   ];
   console.log ("bounds: " + bounds);
   return bounds;
} 

/**
 * Closes the existing context menu if it is present on the page.
 *
 * This function removes the context menu from the document if it exists,
 * preventing multiple menus from stacking.
 */
function closeContextMenu() {
    let oldMenu = document.getElementById("context-menu");
    if (oldMenu) {
        document.body.removeChild(oldMenu);
    }
}

/**
 * Adds a new competitor with user input for name and selecting color by click.
 */
function newCompetitor (theLat, theLon) {
   let palette = `<div id="colorPalette" style="display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 10px;">`;
   colorMap.forEach((color, index) => {
      palette += `<div class="color-choice" data-index="${index}" style="
                  width: 24px;
                  height: 24px;
                  border-radius: 50%;
                  background-color: ${color};
                  border: 2px solid #333;
                  cursor: pointer;
                " title="Color ${index}">
                </div>`;
   });
   palette += `</div>`;

   Swal.fire({
      title: 'Add New Competitor',
      html: `
         <input id="competitorName" class="swal2-input" placeholder="Name" autofocus>
         ${palette}
       `,
      focusConfirm: false,
      showCancelButton: true,
      didOpen: () => {
         let selected = null;
         const colorChoices = document.querySelectorAll('.color-choice');
         colorChoices.forEach(choice => {
            choice.addEventListener('click', () => {
               colorChoices.forEach(c => c.style.outline = 'none');
               choice.style.outline = '3px solid #f00'; // Rouge pour la sélection
               selected = parseInt(choice.getAttribute('data-index'), 10);
          // Stocke l'index sélectionné dans un attribut temporaire
               Swal.getPopup().setAttribute('data-selected-color', selected);
            });
         });
      },
      preConfirm: () => {
         const name = document.getElementById('competitorName').value.trim();
         const selectedColor = Swal.getPopup().getAttribute('data-selected-color');

         if (!name) {
            Swal.showValidationMessage('Please enter a name.');
            return false;
         }
         if (selectedColor === null) {
            Swal.showValidationMessage('Please select a color.');
            return false;
         }

         return { name, color: parseInt(selectedColor, 10) };
      }
   }).then((result) => {
      if (result.isConfirmed) {
         const { name, color } = result.value;
         let newCompetitor = { name, lat: theLat, lon: theLon, color, marker: {} };
         competitors.push(newCompetitor);
         addMarker(newCompetitor);
         setBoat (newCompetitor, theLat, theLon);
      }
   });
}

/**
 * Displays a custom context menu at the mouse click position.
 *
 * This function retrieves the latitude and longitude from the event,
 * creates a new context menu element, and positions it based on the 
 * mouse's screen coordinates.
 *
 * @param {Object} e - The event object containing the click position and map coordinates.
 * @param {Object} e.latlng - The latitude and longitude of the clicked point.
 * @param {number} e.latlng.lat - The latitude of the clicked location.
 * @param {number} e.latlng.lng - The longitude of the clicked location.
 * @param {Object} e.originalEvent - The original DOM event containing screen coordinates.
 * @param {number} e.originalEvent.clientX - The X coordinate of the click event on the screen.
 * @param {number} e.originalEvent.clientY - The Y coordinate of the click event on the screen.
 */
function showContextMenu(e) {
   closeContextMenu();

   const lat = e.latlng.lat.toFixed(6);
   const lon = e.latlng.lng.toFixed(6);

   let menu = document.createElement("div");
   menu.id = "context-menu";
   menu.style.top = `${e.originalEvent.clientY}px`;
   menu.style.left = `${e.originalEvent.clientX}px`;

   // Boutons pour chaque compétiteur avec cercle coloré
   let buttons = competitors.map((c, index) => {
      const color = colorMap[c.color % colorMap.length];
      return `
         <button class="context-button" onclick="setBoat(competitors[${index}], ${lat}, ${lon})">
             <span class="color-dot" style="background-color: ${color};"></span>
             Set ${c.name}
         </button>
      `;
   }).join("");

   buttons += `<button class="context-button" onclick="newCompetitor(${lat}, ${lon})">Add Boat</button>`;
   buttons += `<hr style="margin: 6px 0;">`;

   buttons += `
      <button class="context-button" onclick="addWaypoint(${lat}, ${lon})">Add Waypoint or Destination</button>
      <button class="context-button" onclick="resetWaypoint()">Reset Waypoints and Destination</button>
      <button class="context-button" onclick="addPOI(${lat}, ${lon})">Add POI</button>
      <button class="context-button" onclick="deleteAllPOIs()">Delete all POIs</button>
   `;

   menu.innerHTML = buttons;
   document.body.appendChild(menu);
   document.addEventListener("click", closeContextMenu, { once: true });
}

/**
 * Updates the boat's position and marker based on the selected timestamp from Windy.
 *
 * This function calculates the corresponding index in the route data based on 
 * the selected timestamp, updates the boat's position, and moves the marker accordingly.
 *
 * @param {number} selectedTimestamp - The selected timestamp (in seconds) from Windy's timeline.
 */
function updateRouteDisplay (selectedTimestamp) {
   //const boatName = Object.keys(route)[0]; // Extract first key from response
   console.log ('Met à jour la route pour la date :', new Date(selectedTimestamp * 1000).toISOString());
   if (! route) return;
   //let firstTrack = route [boatName].track;

   let startTimeSec = Math.floor(routeParam.startTime.getTime() / 1000);
   index = Math.round((selectedTimestamp - startTimeSec) / routeParam.isoStep);
   console.log ("index avant: " + index);
   
   //if (index >= firstTrack.length) index = firstTrack.length - 1;
   //else 
   if (index < 0) index = 0;
   console.log ("index apres: " + index);

   const boatNames = Object.keys(route);
   console.log ("boatNames:", boatNames);
   boatNames.forEach((name, i) => {
      let iComp = competitors.findIndex (c => c.name === name); // index of current boat
      if ((iComp >= 0) && (! name.startsWith("_"))) {
         let track = route [name].track;
         // console.log ("boatName: ", name, "iComp = ", iComp, "track = " + track);
         if (track.length !== 0 && index < track.length) {
            let newLatLng = track [index].slice (1, 3); // New position
            competitors [iComp].marker.setLatLng (newLatLng);                   // Move the mark
            if (index !==  track.length -1) updateHeading (competitors [iComp], track);
            updateStatusBar (route);
            updateBindPopup (competitors [iComp]);
         }
      }
   });
}

/**
 * Add a marker to competitor
 * @param {Object} competitor with at least name, lat, lon
 * @param {number} index of competitor.
 */
function addMarker (competitor, iComp) {
   let marker = L.marker([competitor.lat, competitor.lon], {
      icon: BoatIcon,
   }).addTo(map);
   marker.bindPopup (popup4Comp (competitor));
   // if (iComp === 0) marker.openPopup(); Only open for first competitor

   competitor.marker = marker;
}

/**
 * Recreate a marker for competitor
 * @param {Object} competitor with at least name, lat, lon
 * @param {number} index of competitor.
 */
function refreshMarker (competitor, iComp) {
   if (competitor.marker)
      competitor.marker.remove ();
   let marker = L.marker([competitor.lat, competitor.lon], {
      icon: BoatIcon,
   }).addTo(map);
   marker.bindPopup (popup4Comp (competitor));
   competitor.marker = marker;
   if (myWayPoints.length > 0) {
      let heading = orthoCap(competitor.lat, competitor.lon, myWayPoints [0][0], myWayPoints [0][1]);
      competitor.marker._icon.setAttribute('data-heading', heading); 
      updateIconStyle (competitor.marker);
   }
}