/* eslint-disable */
// MapStage — REAL Mapbox GL canvas with château pins and parcel layer
// coloured per encépagement. On chateau pick we flyTo + zoom and load
// /parcels/<id>.geojson, painting each feature by properties.cepage.

const MAPBOX_TOKEN = "pk.eyJ1IjoiY21nYzMzIiwiYSI6ImNtYXpoYnlhYjBmYXYyanF6ZWV4OWZoYmUifQ.Nzcwdll49bZjBHStwmB5sg";

// Map parcel `cepage` strings (camelCase, mixed case) to GRAPES keys (hyphen).
const CEPAGE_TO_GRAPE = {
  cabernetSauvignon: "cabernet-sauvignon",
  cabernetsauvignon: "cabernet-sauvignon",
  CabernetSauvignon: "cabernet-sauvignon",
  Merlot:            "merlot",
  merlot:            "merlot",
  cabernetFranc:     "cab-franc",
  cabernetfranc:     "cab-franc",
  CabernetFranc:     "cab-franc",
  petitVerdot:       "verdot",
  petitverdot:       "verdot",
  PetitVerdot:       "verdot",
  malbec:            "malbec",
  Malbec:            "malbec",
  cot:               "cot",
  chardonnay:        "chardonnay",
  Chardonnay:        "chardonnay",
  sauvignonBlanc:    "sauvignon-blanc",
  sauvignonblanc:    "sauvignon-blanc",
  sauvignonGris:     "sauvignon-gris",
  sauvignongris:     "sauvignon-gris",
  semillon:          "semillon",
  Semillon:          "semillon",
  muscadelle:        "muscadelle",
  Muscadelle:        "muscadelle",
  jacheres:          "jacheres",
  Jacheres:          "jacheres",
  carmenere:         "carmenere",
  Carmenere:         "carmenere",
  grenache:          "grenache",
  furmint:           "furmint",
  harslevelu:        "harslevelu",
  Harslevelu:        "harslevelu",
  zeta:              "zeta",
  zinfandel:         "zinfandel",
  Zinfandel:         "zinfandel",
  viognier:          "viognier",
  oldVines:          "old-vines",
  oldvines:          "old-vines",
  grosManseng:       "gros-manseng",
  colombard:         "colombard",
};

// Country fly-to centers and default zoom
const COUNTRY_VIEW = {
  france:    { center: [-0.56667,  44.833328], zoom: 9.2 },
  argentine: { center: [-68.954668, -33.033761], zoom: 9 },
  chile:     { center: [-71.133,    -32.869],    zoom: 9 },
  italie:    { center: [ 11.14927,   43.612256], zoom: 9 },
  usa:       { center: [-122.392322, 38.443754], zoom: 9 },
  hongrie:   { center: [ 21.305377,  48.167702], zoom: 9 },
};

// Resolve a parcel `cepage` string into an array of hex colors
// (one per cepage). Handles compounds like "merlot/cabernetFranc".
function cepageToColors(raw) {
  const { GRAPES } = window.CMGC_DATA;
  if (!raw) return ["#666"];
  const norm = String(raw)
    .normalize("NFD").replace(/[\u0300-\u036f]/g, "")
    .toLowerCase();
  const tokens = norm.split(/[\/,;|\s]+/).filter(Boolean);
  const colors = [];
  for (const tok of tokens) {
    let hit = null;
    for (const [parcelKey, grapeKey] of Object.entries(CEPAGE_TO_GRAPE)) {
      if (parcelKey.toLowerCase() === tok) { hit = GRAPES[grapeKey] || "#888"; break; }
    }
    if (!hit && GRAPES[tok]) hit = GRAPES[tok];
    if (!hit && tok === "cabernet") hit = GRAPES["cabernet-sauvignon"];
    if (hit && !colors.includes(hit)) colors.push(hit);
  }
  return colors.length ? colors : ["#666"];
}

// --------------------------------------------------------------------
// SOIL PATTERNS (sols / sous-sols)
// --------------------------------------------------------------------
// Same vocabulary as the bands in the Encépagement legend (pebbles,
// hatch, stipple, wave, crystal, diagonal, speckle) but redrawn as
// dense, fine-grain tiles so they read as a TEXTURE applied uniformly
// over the parcel — not as discrete sub-zones of color. The card
// tiles can use larger, sparser marks because the band reads as a
// stratum at one glance; on the map, large sparse marks fool the eye
// into seeing two-tone parcels (which would imply a cépage blend or a
// terroir transition, neither of which is what we want here — the
// data is about depth, not horizontal zoning).
const SOIL_PATTERN_SVG = {
  // Galets — dense field of small ellipses, low contrast
  pebbles:
    `<svg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 22 22'>
      <g fill='rgba(0,0,0,0.32)'>
        <ellipse cx='3'  cy='4'  rx='1.8' ry='1.2'/>
        <ellipse cx='10' cy='3'  rx='2.2' ry='1.4'/>
        <ellipse cx='17' cy='5'  rx='1.7' ry='1.1'/>
        <ellipse cx='5'  cy='11' rx='2.0' ry='1.3'/>
        <ellipse cx='13' cy='10' rx='1.6' ry='1.1'/>
        <ellipse cx='19' cy='12' rx='1.8' ry='1.2'/>
        <ellipse cx='2'  cy='18' rx='1.5' ry='1.0'/>
        <ellipse cx='9'  cy='17' rx='2.1' ry='1.4'/>
        <ellipse cx='16' cy='19' rx='1.7' ry='1.2'/>
      </g>
    </svg>`,
  // Hachures — fines, régulières, brisées
  hatch:
    `<svg xmlns='http://www.w3.org/2000/svg' width='14' height='10' viewBox='0 0 14 10'>
      <g stroke='rgba(0,0,0,0.38)' stroke-width='0.7' stroke-linecap='round'>
        <line x1='1'  y1='2' x2='5'  y2='2'/>
        <line x1='8'  y1='2' x2='12' y2='2'/>
        <line x1='3'  y1='5' x2='7'  y2='5'/>
        <line x1='10' y1='5' x2='13' y2='5'/>
        <line x1='1'  y1='8' x2='5'  y2='8'/>
        <line x1='8'  y1='8' x2='12' y2='8'/>
      </g>
    </svg>`,
  // Pointillés — fin sablon dense
  stipple:
    `<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'>
      <g fill='rgba(0,0,0,0.36)'>
        <circle cx='1' cy='1.5' r='0.5'/><circle cx='4' cy='1' r='0.5'/>
        <circle cx='7' cy='2'   r='0.5'/><circle cx='9' cy='4' r='0.5'/>
        <circle cx='2' cy='5'   r='0.5'/><circle cx='5' cy='4' r='0.5'/>
        <circle cx='8' cy='6'   r='0.5'/><circle cx='3' cy='8' r='0.5'/>
        <circle cx='6' cy='9'   r='0.5'/><circle cx='9' cy='8' r='0.5'/>
        <circle cx='1' cy='9'   r='0.5'/>
      </g>
    </svg>`,
  // Ondes — fines lignes ondulées serrées
  wave:
    `<svg xmlns='http://www.w3.org/2000/svg' width='16' height='10' viewBox='0 0 16 10'>
      <g stroke='rgba(0,0,0,0.34)' stroke-width='0.6' fill='none'>
        <path d='M0 2.5 Q 4 1 8 2.5 T 16 2.5'/>
        <path d='M0 6   Q 4 4.5 8 6   T 16 6'/>
        <path d='M0 9   Q 4 7.5 8 9   T 16 9'/>
      </g>
    </svg>`,
  // Cristaux — petits losanges réguliers
  crystal:
    `<svg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14'>
      <g fill='rgba(0,0,0,0.30)'>
        <polygon points='3,1 5,3 3,5 1,3'/>
        <polygon points='10,4 12,6 10,8 8,6'/>
        <polygon points='4,8 6,10 4,12 2,10'/>
        <polygon points='11,10 13,12 11,14 9,12'/>
      </g>
    </svg>`,
  // Diagonales — hachures fines régulières en biais
  diagonal:
    `<svg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'>
      <g stroke='rgba(0,0,0,0.34)' stroke-width='0.6' stroke-linecap='round'>
        <line x1='-1' y1='5' x2='5' y2='-1'/>
        <line x1='3'  y1='9' x2='9' y2='3'/>
        <line x1='-1' y1='9' x2='1' y2='7'/>
      </g>
    </svg>`,
  // Mouchetures — très fin grain dense
  speckle:
    `<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'>
      <g fill='rgba(0,0,0,0.38)'>
        <circle cx='1.5' cy='1.5' r='0.55'/><circle cx='4.5' cy='2.5' r='0.5'/>
        <circle cx='7.5' cy='1' r='0.55'/><circle cx='9' cy='4'    r='0.55'/>
        <circle cx='2.5' cy='4.5' r='0.55'/><circle cx='6' cy='5'  r='0.55'/>
        <circle cx='8.5' cy='7' r='0.55'/><circle cx='1' cy='7.5'  r='0.55'/>
        <circle cx='4' cy='8'   r='0.5'/><circle cx='7' cy='9'     r='0.55'/>
      </g>
    </svg>`,
};

// Async — rasterise an SVG string to a Mapbox image and register it
// under "soil-<name>". Resolves once the image is on the map.
function loadSoilPattern(map, name) {
  return new Promise((resolve) => {
    const imgName = `soil-${name}`;
    if (map.hasImage(imgName)) return resolve(imgName);
    const svg = SOIL_PATTERN_SVG[name];
    if (!svg) return resolve(null);
    // Upscale: pattern art at the natural svg size is too coarse on a
    // retina map. Render @2x then declare pixelRatio:2 so Mapbox draws
    // it at the original art size while keeping crisp edges.
    const scale = 2;
    const match = svg.match(/width='(\d+)' height='(\d+)'/);
    const w = match ? parseInt(match[1], 10) : 32;
    const h = match ? parseInt(match[2], 10) : 32;
    const blob = new Blob([svg], { type: "image/svg+xml" });
    const url = URL.createObjectURL(blob);
    const img = new Image();
    img.onload = () => {
      const cvs = document.createElement("canvas");
      cvs.width = w * scale; cvs.height = h * scale;
      const ctx = cvs.getContext("2d");
      ctx.drawImage(img, 0, 0, cvs.width, cvs.height);
      const data = ctx.getImageData(0, 0, cvs.width, cvs.height);
      if (!map.hasImage(imgName)) {
        map.addImage(imgName, { width: cvs.width, height: cvs.height, data: data.data }, { pixelRatio: 2 });
      }
      URL.revokeObjectURL(url);
      resolve(imgName);
    };
    img.onerror = () => { URL.revokeObjectURL(url); resolve(null); };
    img.src = url;
  });
}

function loadAllSoilPatterns(map) {
  return Promise.all(Object.keys(SOIL_PATTERN_SVG).map(n => loadSoilPattern(map, n)));
}

// Build (and cache) a diagonal-stripe pattern image for a given pair of
// colors and register it with the map. Returns the pattern name.
const PATTERN_CACHE = new Set();
function ensureStripePattern(map, colorA, colorB) {
  const name = `stripe-${colorA}-${colorB}`.replace(/#/g, "");
  if (PATTERN_CACHE.has(name) || map.hasImage(name)) {
    PATTERN_CACHE.add(name);
    return name;
  }
  const size = 24;
  const cvs = document.createElement("canvas");
  cvs.width = size; cvs.height = size;
  const ctx = cvs.getContext("2d");
  ctx.fillStyle = colorA;
  ctx.fillRect(0, 0, size, size);
  ctx.fillStyle = colorB;
  // Diagonal band covering half the tile
  ctx.beginPath();
  ctx.moveTo(size, 0);
  ctx.lineTo(size, size / 2);
  ctx.lineTo(size / 2, size);
  ctx.lineTo(0, size);
  ctx.lineTo(0, size / 2);
  ctx.lineTo(size / 2, 0);
  ctx.closePath();
  ctx.fill();
  const img = ctx.getImageData(0, 0, size, size);
  map.addImage(name, { width: size, height: size, data: img.data }, { pixelRatio: 2 });
  PATTERN_CACHE.add(name);
  return name;
}

// Build (and cache) a diagonal-hatch pattern in a single cépage color, on a
// transparent background — meant to sit as a `fill-pattern` layer ON TOP of
// the base `_color` fill (the parcel's first/majority cépage), the same way
// the sols/sous-sols texture pass sits over its color. Used for parcelles
// complantées: base color = first cépage, hatch color = second cépage, so a
// compound parcel reads as "these two grapes" instead of the old flat pink
// that had no relation to the actual encépagement (and didn't match the
// two-tone swatch in the Encépagement panel).
function ensureCepageHatchPattern(map, colorHex) {
  const key = String(colorHex).replace(/#/g, "").toLowerCase();
  const name = `cepage-hatch-${key}`;
  if (PATTERN_CACHE.has(name) || map.hasImage(name)) {
    PATTERN_CACHE.add(name);
    return name;
  }
  const tile = 16, period = 8, scale = 2;
  const S = tile * scale;
  const cvs = document.createElement("canvas");
  cvs.width = S; cvs.height = S;
  const ctx = cvs.getContext("2d");
  ctx.strokeStyle = colorHex;
  ctx.lineWidth = 2.4;
  ctx.lineCap = "round";
  // Family of parallel 45° lines, spaced `period` apart, swept wide enough
  // to cover the tile — canvas clips to the visible S×S square, and since
  // the line family is periodic this crops to a seamlessly tileable image.
  for (let n = -1; n <= tile / period + 1; n++) {
    const x0 = n * period * scale;
    ctx.beginPath();
    ctx.moveTo(x0, 0);
    ctx.lineTo(x0 + S, S);
    ctx.stroke();
  }
  const img = ctx.getImageData(0, 0, S, S);
  map.addImage(name, { width: S, height: S, data: img.data }, { pixelRatio: scale });
  PATTERN_CACHE.add(name);
  return name;
}

// Alias map: chateau id (data.js `c`) → parcel filename (without extension)
// for cases where the canonical id and the parcels/ filename don't match.
const PARCEL_ALIAS = {
  "lhb":      "larrivet-haut-brion",
  "shl":      "smith-haut-lafitte",
  "gcd":      "grand-corbin-despagne",
  "sociando": "sociando-mallet",
};

// Walk a FeatureCollection and stamp `_color` / `_color2` on each feature
// according to the current displayMode. This is the SINGLE place where
// the parcel-fill paint expression reads from — Mapbox re-renders the
// layer when we setData() the source.
//
//   mode === "cepages"   → cépage color (real CMGC grape tokens)
//   mode === "sols"      → sol color from CMGC_TERROIRS.SOLS
//   mode === "sous-sols" → sous-sol color from CMGC_TERROIRS.SOUS_SOLS
//
// Mutates the geojson in place.
function applyParcelColors(gj, chateau, mode, year, map) {
  if (!gj || !Array.isArray(gj.features)) return;
  // For "millesimes" mode the whole château flips to a single vintage
  // color (the note is the same across all of one estate's parcels —
  // see millesime data structure). Resolve once outside the loop.
  let vintageColor = null;
  if (mode === "millesimes" && window.CMGC_MILLESIMES) {
    const v = window.CMGC_MILLESIMES.vintageForChateau(chateau, year);
    vintageColor = v ? window.CMGC_MILLESIMES.colorForNote(v.note) : "#3D4452";
  }
  // Some châteaux (Pavie, Marquis de Terme…) ship a SINGLE parcels file
  // that mixes cépage-only features and sol-only features (two distinct
  // parcel splits overlaid on the same estate). For those "mixed" files,
  // a feature missing the current mode's own property is NOT a château
  // with no data (which should fall back to a château/appellation
  // default so it still reads as something) — it's a feature that
  // belongs to the OTHER view and must stay invisible, or the two
  // layers paint on top of each other. We only apply this strict
  // hide-if-not-mine rule when the file actually mixes both kinds;
  // single-purpose files keep the old fallback behaviour untouched.
  const hasCepageFeat = gj.features.some(f => f.properties && f.properties.cepage);
  const hasSolFeat     = gj.features.some(f => f.properties && f.properties.sol);
  const mixed = hasCepageFeat && hasSolFeat;

  gj.features.forEach((f, i) => {
    if (!f.properties) f.properties = {};
    delete f.properties._color2;  // reset blend pass (cépages only)
    delete f.properties._pattern; // reset texture pass (sols/sous-sols only)
    delete f.properties._color;   // reset — re-set below, or left absent to hide
    if (mode === "sols" || mode === "sous-sols") {
      if (mixed && !f.properties.sol) return; // cépage-only feature: stay hidden
      f.properties._color = window.CMGC_TERROIRS.colorForParcel(f, chateau, i, mode);
      const pat = window.CMGC_TERROIRS.patternForParcel(f, chateau, i, mode);
      if (pat) f.properties._pattern = `soil-${pat}`;
    } else if (mode === "millesimes") {
      f.properties._color = vintageColor;
    } else {
      if (mixed && !f.properties.cepage) return; // sol-only feature: stay hidden
      const cols = cepageToColors(f.properties.cepage);
      if (cols.length >= 2) {
        // Parcelle complantée : couleur du premier cépage + hachures dans
        // la couleur du second, au lieu d'un rose arbitraire sans rapport
        // avec l'encépagement réel — voir ensureCepageHatchPattern.
        f.properties._color = cols[0];
        if (map) f.properties._pattern = ensureCepageHatchPattern(map, cols[1]);
      } else {
        f.properties._color = cols[0];
      }
    }
  });
}

// Compute one centroid per appellation from the chateau coordinates.
// Returns: [{ a: appellationName, country, center: [lng,lat], count }]
// Distance approchée entre deux points, en km.
function kmEntre(a, b) {
  const k = Math.cos((a[1] + b[1]) / 2 * Math.PI / 180);
  return Math.hypot((a[0] - b[0]) * 111.320 * k, (a[1] - b[1]) * 110.540);
}

// Au-delà de cette distance, deux châteaux de la même appellation ne
// partagent pas la même pastille.
const CLUSTER_LIEN_KM = 8;

// Une appellation, une pastille — SAUF quand ses châteaux sont dispersés.
//
// Le centroïde arithmétique tombe entre les châteaux, ce qui ne pose pas de
// problème tant qu'ils sont groupés : à Margaux ils tiennent dans 4,9 km, à
// Pauillac dans 6,3. Le Haut-Médoc, lui, court sur toute la presqu'île :
// 37,8 km entre Sociando-Mallet au nord et Agassac au sud. Sa pastille
// atterrissait donc en rase campagne, à une quinzaine de kilomètres du
// château le plus proche, et le clic zoomait sur des champs sans une seule
// propriété à l'écran.
//
// Les membres sont donc regroupés par proximité (lien simple, seuil
// CLUSTER_LIEN_KM) avant de calculer un centre. Les appellations compactes
// ne bougent pas — le seuil porte sur la distance entre voisins, pas sur
// l'étendue totale, si bien que les quatorze châteaux de Pauillac restent
// une seule pastille. Quatre appellations se découpent aujourd'hui :
// Haut-Médoc (3), Médoc, Entre-deux-Mers et Napa Valley (2 chacune).
function buildAppellationClusters(chateaux) {
  const groups = {};
  chateaux.forEach(ch => {
    const key = ch.country + "|" + ch.a;
    if (!groups[key]) groups[key] = { a: ch.a, country: ch.country, items: [] };
    groups[key].items.push(ch);
  });
  const out = [];
  Object.values(groups).forEach(g => {
    const n = g.items.length;
    const parent = Array.from({ length: n }, (_, i) => i);
    const trouve = (a) => { while (parent[a] !== a) { parent[a] = parent[parent[a]]; a = parent[a]; } return a; };
    for (let i = 0; i < n; i++) {
      for (let j = i + 1; j < n; j++) {
        if (kmEntre(g.items[i].ll, g.items[j].ll) < CLUSTER_LIEN_KM) {
          const a = trouve(i), b = trouve(j);
          if (a !== b) parent[a] = b;
        }
      }
    }
    const sous = new Map();
    for (let i = 0; i < n; i++) {
      const r = trouve(i);
      if (!sous.has(r)) sous.set(r, []);
      sous.get(r).push(g.items[i]);
    }
    sous.forEach(items => {
      out.push({
        a: g.a,
        country: g.country,
        center: [
          items.reduce((s, c) => s + c.ll[0], 0) / items.length,
          items.reduce((s, c) => s + c.ll[1], 0) / items.length,
        ],
        count: items.length,
        items,
      });
    });
  });
  return out;
}

// Zoom thresholds for the reveal cascade
const ZOOM_NEAR = 11.0;  // ≥ this → individual château logos
const ZOOM_MID  = 9.0;   // between MID and NEAR → fade in pins gradually
function zoomBucket(z) {
  if (z >= ZOOM_NEAR) return "near";
  if (z >= ZOOM_MID)  return "mid";
  return "far";
}

// Au-delà de ce zoom, chaque pastille porte son nom en clair.
//
// Pourquoi un nom et pas seulement le logo : les logos des châteaux sont
// des lockups complets — armoiries, nom sur trois ou quatre lignes,
// mention « grand cru classé en 1855 », le tout sur un carré blanc à
// larges marges (voir pedesclaux.png, pontet-canet.png). Réduits aux
// 46 px de la pastille, les caractères font 2 px : la pastille ne dit
// plus que « il y a un château ici », jamais lequel. Seule la pastille
// active, à x1,4, devenait à peu près identifiable — d'où l'impression
// que les voisines étaient illisibles. Le nom sous la pastille rend
// l'identité au premier regard, et au tactile où il n'y a pas de survol
// pour révéler l'infobulle.
//
// Le seuil est haut (14) parce qu'il n'y a pas d'évitement de collision :
// à 11 les quatorze pastilles de Pauillac tiennent dans 500 px et les
// noms se chevaucheraient. À 14 il en reste une poignée à l'écran, et
// c'est de toute façon le régime où l'on arrive en cliquant un château
// (le survol vole jusqu'à 15,2).
const ZOOM_LABELS = 14.0;

// ---- Relief ----
//
// Pas de réglage exposé : le relief et les courbes sont toujours actifs et
// calibrés par région. Un curseur n'aurait rien à dire d'utile ici, parce
// que le bon réglage ne dépend pas du goût de l'utilisateur mais de
// l'amplitude du terroir qu'il regarde.
//
// L'exagération du MNT ne figure PLUS ici : elle se mesure. Un pays est
// une mauvaise maille — la France contient le Médoc, qui tient sous 30 m
// et disparaît à l'échelle réelle, et le plateau de Saint-Émilion, qui
// monte à 90 m et que x4 transformait en montagne. Voir
// `exaggerationFor`, alimenté par le dénivelé que le worker des courbes
// relève dans la fenêtre courante.
// `interval` équidistance des courbes. Elle suit la même logique : 2 m se
//            lit sur une croupe médocaine, mais sur les 1000 m de
//            dénivelé du piémont andin ça ferait 500 niveaux — illisible
//            et hors de prix. 25 m y donne la même densité à l'écran.
// `labelEvery` pas des altitudes chiffrées, en mètres. Par défaut c'est une
//            courbe sur cinq, la convention topographique. Le Médoc y
//            échappe : une propriété tient entre 0 et 30 m, une sur cinq
//            n'y donnerait que trois nombres. On y chiffre donc tous les
//            5 m — ce qui suppose que 5 soit un multiple de
//            l'équidistance, d'où le pas de 2,5 m plutôt que 2 m.
const RELIEF_BY_COUNTRY = {
  france:    { interval: 2.5, labelEvery: 5 },  // Médoc, Saint-Émilion : tout est dans les croupes
  hongrie:   { interval: 10 },  // Tokaj : collines volcaniques
  italie:    { interval: 10 },  // Bolgheri, Toscane
  usa:       { interval: 10 },  // Napa : Vaca, Howell Mountain
  chile:     { interval: 25 },  // Seña, vallée de l'Aconcagua
  argentine: { interval: 25 },  // Cheval des Andes, piémont andin
};
const RELIEF_DEFAULT = { interval: 10 };
const reliefFor = (country) => RELIEF_BY_COUNTRY[country] || RELIEF_DEFAULT;

// Exagération déduite du dénivelé RÉELLEMENT présent dans la vue, en
// mètres (percentiles 2/98, relevés par contours.worker.js).
//
// Le critère est celui du terrain et non celui d'un pays. Un terroir qui
// tient sous une trentaine de mètres — le Médoc, Pessac-Léognan — paraît
// plat à l'échelle réelle alors que tout s'y joue sur 2 à 5 m de croupe :
// il faut le creuser. Plus le dénivelé grandit, moins il en a besoin, et
// au-delà de quelques centaines de mètres il n'en a plus aucun : Napa
// rendue à x1,6 se dressait en arêtes que le terrain n'a pas.
//
// La décroissance est CONTINUE et non par paliers. Des paliers font sauter
// le relief quand on se déplace : à la frontière des 30 m, un mètre
// d'amplitude en plus faisait passer de x4 à x2,5, soit un dénivelé
// apparent qui tombait de 120 à 78 m d'un coup.
//
// L'exposant 0,6 plutôt que 1 : à dénivelé apparent égal, la côte de
// Saint-Émilion — une marche courte et raide — se lit moins qu'une croupe
// médocaine longue et douce. La pente compte autant que l'amplitude, donc
// on laisse le relief apparent croître un peu avec le terrain réel.
//
// REPÈRE, le seul chiffre à bouger : x4 sur les 24 m de Saint-Julien.
// L'arrondi au quart évite de rappeler `setTerrain` — le geste le plus
// coûteux de la carte, ~350 ms de blocage mesuré — pour une variation
// invisible entre deux vues voisines.
const EX_REPERE = 4 * Math.pow(24, 0.6);
function exaggerationFor(amplitude) {
  if (amplitude == null || !(amplitude > 0)) return 1;
  const brut = EX_REPERE / Math.pow(amplitude, 0.6);
  return Math.min(4, Math.max(1, Math.round(brut * 4) / 4));
}

// En dessous, les courbes couvriraient toute l'appellation : illisible et
// cher à calculer. On les masque plutôt que de les tracer pour rien — ce
// qui laisse aussi la vue d'accueil (zoom 9) totalement intacte.
const CONTOUR_MIN_ZOOM = 12.5;

// Seuil d'activation du relief 3D, avec hystérésis.
//
// Mesuré : `setTerrain` coûte à lui seul ~390 ms de blocage du thread
// principal par geste de caméra dans cette carte — et ça ne se règle ni
// par la résolution du MNT ni par le nombre de sources (testés, sans
// effet). C'est le prix de fond du relief Mapbox : chaque couche drapée
// du fond satellite doit être re-rendue sur le maillage du terrain, et
// ce style en compte beaucoup.
//
// Le relief est donc réservé au moment où il apporte quelque chose :
// quand on est descendu sur une appellation ou un château. En vue
// panoramique — la majeure partie de la navigation — la carte retrouve
// exactement la fluidité qu'elle avait avant.
//
// Les deux seuils évitent le battement quand on s'arrête pile dessus.
const TERRAIN_ON_ZOOM  = 12.5;
const TERRAIN_OFF_ZOOM = 12.2;

const MapStage = React.forwardRef(function MapStage(
  { country, activeId, hoveredId, displayMode, year, onPick, onHover, onBearing },
  ref
) {
  const containerRef = React.useRef(null);
  const mapRef       = React.useRef(null);
  const markersRef   = React.useRef({});            // id → mapboxgl.Marker
  const clusterMarkersRef = React.useRef([]);       // appellation cluster markers
  const activeIdRef  = React.useRef(null);
  // Vrai seulement si l'URL désigne déjà un château au chargement : c'est ce
  // cas-là, et lui seul, qui doit se poser directement au lieu d'y voler. Une
  // carte ouverte sans ancre garde son vol au premier clic, qui dit d'où l'on
  // part.
  const premierCadrageRef = React.useRef(
    typeof location !== "undefined" && !!location.hash.replace("#", "").trim()
  );
  const parcelsRef   = React.useRef(null);          // last loaded parcel FeatureCollection (raw)
  const parcelChateauRef = React.useRef(null);      // chateau matching parcelsRef
  const modeRef      = React.useRef(displayMode || "cepages");
  const yearRef      = React.useRef(year);
  const onPickRef    = React.useRef(onPick);
  const onHoverRef   = React.useRef(onHover);
  const contourJobRef = React.useRef(0);              // jeton anti-course des rebuilds
  const contourTimerRef = React.useRef(null);
  const readyRef     = React.useRef(false);
  const countryRef   = React.useRef(country);
  const terrainOnRef = React.useRef(false);   // relief 3D actuellement posé ?
  const amplitudeRef = React.useRef(null);    // dénivelé mesuré dans la vue, en m
  const exAppliedRef = React.useRef(null);    // exagération réellement posée

  // Imperative handle for the parent — used by the compass "reset to N"
  // affordance to animate bearing back to 0 without us caching the map
  // instance globally.
  React.useImperativeHandle(ref, () => ({
    resetBearing: () => {
      const m = mapRef.current;
      if (m) m.easeTo({ bearing: 0, duration: 600, easing: t => 1 - Math.pow(1 - t, 4) });
    },
  }), []);

  React.useEffect(() => { modeRef.current = displayMode || "cepages"; }, [displayMode]);
  React.useEffect(() => { yearRef.current = year; }, [year]);

  React.useEffect(() => { onPickRef.current = onPick; }, [onPick]);
  React.useEffect(() => { onHoverRef.current = onHover; }, [onHover]);

  // ---- relief : MNT 3D ----
  // Mapbox drape automatiquement les couches fill et line sur le MNT :
  // les parcellaires et les courbes suivent le terrain sans code en plus.
  const applyTerrain = React.useCallback((force) => {
    const map = mapRef.current;
    if (!map) return;
    const ex = exaggerationFor(amplitudeRef.current);
    // Hystérésis : une fois allumé, le relief ne s'éteint qu'en repassant
    // sous le seuil bas.
    const z = map.getZoom();
    const on = terrainOnRef.current ? z >= TERRAIN_OFF_ZOOM : z >= TERRAIN_ON_ZOOM;
    // Ne rien réécrire si rien ne change : setTerrain force un recalcul
    // complet du drapage, ce serait le geste le plus coûteux de la carte,
    // répété pour rien à chaque fin de zoom. On repose donc le relief
    // seulement s'il s'allume, s'il s'éteint, ou si l'exagération mesurée
    // a changé de palier.
    const memeEtat = on === terrainOnRef.current;
    const memeEx = !on || ex === exAppliedRef.current;
    if (memeEtat && memeEx && !force) return;
    terrainOnRef.current = on;
    exAppliedRef.current = on ? ex : null;
    try {
      map.setTerrain(on ? { source: "mapbox-dem", exaggeration: ex } : null);
      if (map.getLayer("hillshade")) {
        // Sur 20 m de dénivelé, l'ombre porte autant d'information que la
        // géométrie elle-même : on appuie l'estompage là où le relief est
        // ténu, et on le laisse discret là où il parle tout seul.
        // Hors relief 3D, l'estompage porte seul la lecture du dénivelé :
        // on l'appuie un peu plus qu'à l'origine (0,45) sans revenir au
        // coût du terrain.
        map.setPaintProperty("hillshade", "hillshade-exaggeration",
          on ? (ex >= 3 ? 0.85 : 0.55) : 0.60);
      }
    } catch (e) {
      console.warn("[relief] setTerrain refusé :", e);
    }
  }, []);

  // ---- relief : courbes de niveau ----
  const rebuildContours = React.useCallback(() => {
    const map = mapRef.current;
    if (!map || !readyRef.current) return;
    const src = map.getSource("contours");
    if (!src) return;
    const { interval, labelEvery } = reliefFor(countryRef.current);
    if (!interval || map.getZoom() < CONTOUR_MIN_ZOOM) {
      src.setData({ type: "FeatureCollection", features: [] });
      // On vient de vider la couche : le prochain calcul doit repartir de
      // zéro même si la couverture de tuiles est la même qu'avant.
      if (window.CMGCContours) window.CMGCContours.reset();
      return;
    }
    if (!window.CMGCContours) return;
    // Jeton anti-course : pendant qu'un calcul télécharge ses tuiles,
    // l'utilisateur a souvent déjà bougé. Seul le dernier demandé écrit.
    const job = ++contourJobRef.current;
    window.CMGCContours.build(map, MAPBOX_TOKEN, interval, labelEvery)
      .then((fc) => {
        if (job !== contourJobRef.current) return;
        // null = rien à changer (couverture identique) : on ne renvoie
        // pas les mêmes données à Mapbox pour rien.
        if (!fc) return;
        const s = mapRef.current && mapRef.current.getSource("contours");
        if (s) s.setData(fc);
        // Le dénivelé de la fenêtre vient d'être mesuré : c'est lui qui
        // décide de l'exagération. applyTerrain ne repose le relief que
        // si le palier a changé.
        if (fc.relief && typeof fc.relief.amplitude === "number") {
          amplitudeRef.current = fc.relief.amplitude;
          applyTerrain();
        }
      })
      .catch(() => {});
  }, [applyTerrain]);

  // Les courbes sont recalculées pour la vue courante : on attend la fin
  // du geste plutôt que de relancer à chaque frame de pan.
  const scheduleContours = React.useCallback(() => {
    clearTimeout(contourTimerRef.current);
    contourTimerRef.current = setTimeout(rebuildContours, 200);
  }, [rebuildContours]);

  // ---- init map once ----
  React.useEffect(() => {
    // Ces deux sorties laissaient auparavant un écran vide et une ligne dans
    // une console que personne n'ouvre. Elles préviennent maintenant la
    // sentinelle de map.html, qui affiche le panneau de repli et nomme la
    // cause. Le repli ne dépend d'aucune de ces bibliothèques.
    if (!window.mapboxgl) {
      console.error("mapbox-gl not loaded");
      if (window.__cmgcRessourceKO) window.__cmgcRessourceKO("mapbox-gl");
      return;
    }
    if (typeof window.mapboxgl.supported === "function" && !window.mapboxgl.supported()) {
      console.error("WebGL indisponible : mapboxgl.supported() est faux");
      if (window.__cmgcRessourceKO) window.__cmgcRessourceKO("WebGL");
      return;
    }
    window.mapboxgl.accessToken = MAPBOX_TOKEN;
    const map = new window.mapboxgl.Map({
      container: containerRef.current,
      style:    "mapbox://styles/mapbox/satellite-streets-v12",
      center:   COUNTRY_VIEW.france.center,
      zoom:     COUNTRY_VIEW.france.zoom,
      pitch:    35,
      bearing:  0,
      // L'attribution n'est pas decorative : les conditions de Mapbox, d'OSM
      // et de Maxar imposent de citer la source du fond de carte. Elle etait
      // coupee ici *et* masquee en CSS (.mapboxgl-ctrl-bottom-*), si bien
      // qu'aucune ligne n'apparaissait nulle part. On la garde, en version
      // compacte : elle se deplie au clic sur le (i).
      attributionControl: false,
      cooperativeGestures: false,
    });
    map.addControl(new window.mapboxgl.AttributionControl({ compact: true }),
                   "bottom-right");
    // Une echelle, absente jusqu'ici. Sur un atlas dont l'argument tient a des
    // croupes de deux a cinq metres et a des parcelles de quelques hectares,
    // ne pas pouvoir mesurer une distance est un manque, pas un parti pris.
    map.addControl(new window.mapboxgl.ScaleControl({
      maxWidth: 110, unit: "metric",
    }), "bottom-left");
    mapRef.current = map;
    window.__cmgcMap = map; // debug handle

    map.on("load", () => {
      // Preload soil-texture pattern images (sols / sous-sols mode).
      // Fire-and-forget — they're ready by the time the user toggles
      // away from "Cépages" since rasterising 7 tiny SVGs is fast.
      loadAllSoilPatterns(map);

      // Terrain DEM + hillshade pass.
      // Bordeaux is famously almost-flat — but the croupes de graves
      // (a few metres of relief) are the whole story of the great
      // Médocs; and in Mendoza, Aconcagua, Tokaj, Bolgheri, Napa the
      // altitude/exposure IS the terroir. Hillshade lets the relief
      // breathe through the satellite imagery without us having to
      // tilt to true 3D (which would break a lot of overlay logic).
      try {
        if (!map.getSource("mapbox-dem")) {
          map.addSource("mapbox-dem", {
            type: "raster-dem",
            url: "mapbox://mapbox.mapbox-terrain-dem-v1",
            tileSize: 512,
            maxzoom: 14,
          });
        }
        // Place hillshade BELOW the parcels so the colored fills still
        // pop — the shading lives just above the satellite imagery.
        // Find a label/road layer as the anchor so hillshade doesn't
        // bury place names; fall back to inserting at the top.
        let beforeId;
        const layers = map.getStyle().layers || [];
        for (const l of layers) {
          if (l.type === "symbol") { beforeId = l.id; break; }
        }
        if (!map.getLayer("hillshade")) {
          map.addLayer({
            id: "hillshade",
            source: "mapbox-dem",
            type: "hillshade",
            paint: {
              "hillshade-exaggeration": 0.45,
              "hillshade-shadow-color": "#0A1734",
              "hillshade-highlight-color": "#F7F5EF",
              "hillshade-accent-color": "#9B601A",
              "hillshade-illumination-direction": 335,
            },
          }, beforeId);
        }
      } catch (e) {
        // Un catch muet ici a déjà masqué une régression du relief : on
        // laisse une trace plutôt que de perdre l'estompage en silence.
        console.warn("[relief] mise en place du MNT/estompage impossible :", e);
      }

      // Parcel source + layers (start empty)
      map.addSource("parcels", { type: "geojson", data: { type:"FeatureCollection", features:[] } });
      map.addLayer({
        id: "parcels-fill",
        type: "fill",
        source: "parcels",
        // "has" keeps features without a computed _color fully invisible
        // (mixed cépage+sol files hide the feature that belongs to the
        // OTHER toggle mode — see applyParcelColors) instead of falling
        // back to grey via coalesce.
        filter: ["has", "_color"],
        paint: {
          "fill-color": ["get", "_color"],
          "fill-opacity": [
            "interpolate", ["linear"], ["zoom"],
            10, 0,
            12, 0.55,
            14, 0.78,
            16, 0.85,
          ],
        },
      });
      // Second fill layer: only features with a second cépage. Paints
      // _color2 with reduced opacity on top of _color — perceived as a
      // blended/two-tone parcel without needing fill-pattern (which can
      // break the whole layer if any feature lacks the pattern).
      map.addLayer({
        id: "parcels-fill-blend",
        type: "fill",
        source: "parcels",
        filter: ["has", "_color2"],
        paint: {
          "fill-color": ["coalesce", ["get", "_color2"], "#666"],
          "fill-opacity": [
            "interpolate", ["linear"], ["zoom"],
            10, 0,
            12, 0.30,
            14, 0.45,
            16, 0.50,
          ],
          "fill-antialias": true,
        },
      });
      // Soil-texture pass: stamps the soil pattern (galets, hachures,
      // ondes, etc.) over the colored fill so parcels in sols/sous-sols
      // mode read with the same vocabulary as the Encépagement legend.
      // Filter ["has", "_pattern"] keeps the layer invisible in cépages
      // mode (no pattern is stamped on the features there).
      // Opacity kept moderate so the texture reads as a "grain" sitting
      // ON the color, not as a second color zone — sols/sous-sols is
      // about depth, not horizontal zoning, and a too-strong texture
      // pass made parcels look two-tone (mis-read as cépage blends).
      map.addLayer({
        id: "parcels-fill-pattern",
        type: "fill",
        source: "parcels",
        filter: ["has", "_pattern"],
        paint: {
          "fill-pattern": ["get", "_pattern"],
          "fill-opacity": [
            "interpolate", ["linear"], ["zoom"],
            10, 0,
            12, 0.25,
            14, 0.45,
            16, 0.55,
          ],
        },
      });

      map.addLayer({
        id: "parcels-line",
        type: "line",
        source: "parcels",
        paint: {
          "line-color": "#ffffff",
          "line-width": 0.6,
          "line-opacity": [
            "interpolate", ["linear"], ["zoom"],
            11, 0,
            13, 0.25,
            15, 0.55,
          ],
        },
      });

      // ---- Courbes de niveau ----
      // Source alimentée à la demande par components/Contours.js (marching
      // squares sur le MNT). Ajoutée APRÈS les parcellaires : les courbes
      // se lisent par-dessus les couleurs de sol, comme sur un plan
      // parcellaire de château.
      map.addSource("contours", { type: "geojson", data: { type: "FeatureCollection", features: [] } });
      map.addLayer({
        id: "contours-line",
        type: "line",
        source: "contours",
        layout: { "line-join": "round", "line-cap": "round" },
        paint: {
          // Les courbes sont une couche de lecture posée SUR la photo
          // aérienne, pas le sujet : elles doivent s'effacer devant le
          // parcellaire et le relief. Une ligne sur cinq est maîtresse et
          // porte le tracé ; les intermédiaires restent à la limite du
          // visible, ce qui suffit à lire la pente sans masquer le sol.
          "line-color": ["case", ["get", "major"], "rgba(247,245,239,.70)", "rgba(247,245,239,.36)"],
          "line-width": [
            "interpolate", ["linear"], ["zoom"],
            12, ["case", ["get", "major"], 0.7, 0.45],
            16, ["case", ["get", "major"], 1.2, 0.65],
          ],
          "line-opacity": [
            "interpolate", ["linear"], ["zoom"],
            12.5, 0,
            13.4, 0.7,
            15, 0.9,
          ],
        },
      });
      map.addLayer({
        id: "contours-label",
        type: "symbol",
        source: "contours",
        // Seules les courbes maîtresses sont chiffrées : tous les 10 m
        // dans le Médoc, tous les 50 m à Napa (voir contours.worker.js).
        filter: ["get", "label"],
        layout: {
          "symbol-placement": "line",
          "symbol-spacing": 320,
          "text-field": ["concat", ["to-string", ["get", "ele"]], " m"],
          "text-font": ["DIN Pro Medium", "Arial Unicode MS Regular"],
          "text-size": ["interpolate", ["linear"], ["zoom"], 12, 11, 16, 13],
          "text-letter-spacing": 0.08,
          // LA ligne qui décide si les mètres s'affichent ou non. Le
          // placement des symboles suit l'ordre des couches et les
          // premières servies gagnent les collisions : cette couche est
          // la dernière, elle perdait donc contre chaque nom de lieu, de
          // route et de château du fond satellite — et n'affichait
          // absolument rien. La descendre pour gagner la priorité ne
          // marche pas : les parcellaires sont ajoutés après le fond de
          // carte, les chiffres passeraient sous les couleurs de sol. On
          // force donc le placement ici.
          // `text-ignore-placement` reste à false : les étiquettes du
          // fond de carte, elles, continuent d'éviter les altitudes.
          "text-allow-overlap": true,
          // 28° était trop strict : projetées sur une vue inclinée, les
          // courbes du Médoc plient plus que ça sur la longueur d'un
          // « 10 m », et le placement échouait.
          "text-max-angle": 45,
          "text-padding": 2,
          // Le chiffre s'oriente le long de la courbe (`map`) mais reste
          // debout face à la caméra (`viewport`). Sans cette seconde
          // ligne, Mapbox aligne aussi l'inclinaison sur le sol : à 55°
          // de pitch — l'angle de toutes les vues château — les altitudes
          // étaient couchées sur le terrain et écrasées jusqu'à
          // l'invisible. Elles étaient bien placées, jamais lisibles.
          "text-pitch-alignment": "viewport",
          "text-rotation-alignment": "map",
        },
        paint: {
          // Blanc franc et halo appuyé : le chiffre se pose aussi bien
          // sur une parcelle ocre en plein soleil que sur un bois sombre.
          "text-color": "#ffffff",
          "text-halo-color": "rgba(8,16,34,.90)",
          "text-halo-width": 1.7,
          // Les chiffres apparaissent AVEC les traits, pas après. Le
          // palier 13 → 13,8 les laissait à 0,25 d'opacité vers z13,2,
          // alors que les courbes, elles, sont pleinement dessinées dès
          // 13,4 : on se retrouvait devant un hachurage sans aucune
          // altitude — précisément ce qui rendait la vue Napa illisible.
          "text-opacity": [
            "interpolate", ["linear"], ["zoom"],
            12.5, 0,
            13.2, 1,
          ],
        },
      });

      map.on("rotate", () => onBearing && onBearing(map.getBearing()));

      // Track zoom level → toggle data-zoom on container so CSS can
      // cross-fade between cluster markers (far) and château logos (near).
      const applyZoom = () => {
        const z = map.getZoom();
        const b = zoomBucket(z);
        if (!containerRef.current) return;
        if (containerRef.current.dataset.zoom !== b) {
          containerRef.current.dataset.zoom = b;
        }
        const lbl = z >= ZOOM_LABELS ? "on" : "off";
        if (containerRef.current.dataset.labels !== lbl) {
          containerRef.current.dataset.labels = lbl;
        }
      };
      applyZoom();
      map.on("zoom", applyZoom);

      // Recalcul des courbes à la fin de chaque geste (pan/zoom/rotation) :
      // elles ne couvrent que la vue courante.
      map.on("moveend", scheduleContours);

      // Le relief s'allume et s'éteint avec le zoom (voir TERRAIN_ON_ZOOM).
      // Sur zoomend seulement : inutile de tester à chaque frame.
      map.on("zoomend", () => applyTerrain());

      readyRef.current = true;
      applyTerrain(true);
      rebuildContours();

      // signal ready by adding pins
      window.dispatchEvent(new CustomEvent("mapstage:ready"));

      // La carte est là : la sentinelle de map.html peut renoncer à son filet
      // de 12 secondes. C'est bien « load » qui compte et non le montage de
      // React — les bibliothèques peuvent toutes être en place et le fond de
      // carte ne jamais répondre.
      if (window.__cmgcCarteChargee) window.__cmgcCarteChargee();
    });

    // Une erreur du style, du jeton ou des tuiles ne laisse pas non plus de
    // trace visible : mapbox-gl la journalise et s'arrête là. Seules comptent
    // ici celles qui surviennent avant que la carte ne soit chargée — passé ce
    // point, une tuile manquante n'est pas une raison de couvrir une carte qui
    // fonctionne.
    map.on("error", (e) => {
      console.error("mapbox-gl:", (e && e.error && e.error.message) || e);
      if (!readyRef.current && window.__cmgcRessourceKO) {
        window.__cmgcRessourceKO("fond de carte Mapbox");
      }
    });

    return () => {
      clearTimeout(contourTimerRef.current);
      readyRef.current = false;
      map.remove();
      mapRef.current = null;
    };
  }, []);

  // ---- rebuild pins whenever country changes (after map ready) ----
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map) return;
    const renderPins = () => {
      // remove existing markers
      Object.values(markersRef.current).forEach(m => m.remove());
      markersRef.current = {};
      clusterMarkersRef.current.forEach(m => m.remove());
      clusterMarkersRef.current = [];

      const { CHATEAUX } = window.CMGC_DATA;
      const visible = CHATEAUX.filter(c => c.country === country);

      // Individual château pins (revealed at high zoom)
      visible.forEach(ch => {
        const el = document.createElement("button");
        el.className = "mb-pin";
        el.setAttribute("aria-label", ch.n);
        // L'étiquette laisse tomber le « Château » : sur un atlas de
        // châteaux le mot ne distingue rien, et il double la largeur de
        // l'étiquette — donc les chevauchements — pour rien. Le nom
        // complet reste dans l'infobulle et dans aria-label.
        const short = ch.n.replace(/^Château\s+/i, "");
        el.innerHTML = `<span class="mb-pin-inner" style="font-family: Lexend"><img src="assets/chateau-logos/${ch.l}" alt=""/></span><span class="pin-name">${short}</span><span class="pin-tip">${ch.n}</span>`;
        el.addEventListener("click", (e) => { e.stopPropagation(); onPickRef.current && onPickRef.current(ch); });
        el.addEventListener("mouseenter", () => onHoverRef.current && onHoverRef.current(ch.c));
        el.addEventListener("mouseleave", () => onHoverRef.current && onHoverRef.current(null));
        const marker = new window.mapboxgl.Marker({ element: el, anchor: "center" })
          .setLngLat(ch.ll)
          .addTo(map);
        markersRef.current[ch.c] = marker;
      });

      // Appellation clusters (shown at low/mid zoom)
      const clusters = buildAppellationClusters(visible);
      clusters.forEach(cl => {
        const el = document.createElement("button");
        el.className = "mb-cluster";
        el.setAttribute("aria-label", cl.a);
        el.innerHTML = `
          <span class="mb-cluster-dot"></span>
          <span class="mb-cluster-label">
            <span class="mb-cluster-name">${cl.a}</span>
            <span class="mb-cluster-count">${cl.count} château${cl.count > 1 ? "x" : ""}</span>
          </span>
        `;
        el.addEventListener("click", (e) => {
          e.stopPropagation();
          // Un seul château : on vise son point. Plusieurs : on cadre le
          // groupe, sans quoi un zoom fixe en laisse hors champ — les deux
          // châteaux de Ludon sont à 2,9 km l'un de l'autre, ce que le zoom
          // 11,6 ne tient pas.
          if (cl.count === 1) {
            map.flyTo({ center: cl.center, zoom: ZOOM_NEAR + 0.6, pitch: 45, speed: 0.9, curve: 1.4 });
            return;
          }
          const b = new window.mapboxgl.LngLatBounds();
          cl.items.forEach(c => b.extend(c.ll));
          map.fitBounds(b, {
            padding: 120, pitch: 45, maxZoom: ZOOM_NEAR + 0.6, speed: 0.9, curve: 1.4,
          });
        });
        const marker = new window.mapboxgl.Marker({ element: el, anchor: "center" })
          .setLngLat(cl.center)
          .addTo(map);
        clusterMarkersRef.current.push(marker);
      });

      // fly to country
      const v = COUNTRY_VIEW[country] || COUNTRY_VIEW.france;
      map.flyTo({ center: v.center, zoom: v.zoom, pitch: 35, bearing: 0, speed: 0.9, curve: 1.4 });
    };
    if (map.isStyleLoaded()) renderPins();
    else map.once("load", renderPins);
  }, [country]);

  // ---- handle active château: flyTo + load parcels ----
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map) return;
    activeIdRef.current = activeId;

    // Marque la scène : le CSS y accroche l'effacement des pastilles
    // voisines pendant qu'un domaine est sélectionné (voir map.html).
    if (containerRef.current) containerRef.current.classList.toggle("has-active", !!activeId);

    // update marker active classes + swap logo to white version when active
    Object.entries(markersRef.current).forEach(([id, m]) => {
      const el = m.getElement();
      const wasActive = el.classList.contains("active");
      const isActive  = id === activeId;
      el.classList.toggle("active", isActive);
      if (wasActive !== isActive) {
        const ch2 = window.CMGC_DATA.CHATEAUX.find(c => c.c === id);
        if (ch2 && ch2.lActive) {
          const img = el.querySelector("img");
          if (img) img.src = "assets/chateau-logos/" + (isActive ? ch2.lActive : ch2.l);
        }
      }
    });

    if (!activeId) {
      const src = map.getSource("parcels");
      if (src) src.setData({ type:"FeatureCollection", features:[] });
      return;
    }

    const { CHATEAUX } = window.CMGC_DATA;
    const ch = CHATEAUX.find(c => c.c === activeId);
    if (!ch) return;

    // fly to chateau (don't gate on isStyleLoaded — `once('load')` never
    // refires after the initial load, so the gate would silently swallow
    // every click made after the first style refresh).
    // Premier cadrage : on se pose, on ne vole pas.
    //
    // Quand la carte s'ouvre sur #<château> — c'est le cas de la croix des
    // pages château et de tout lien partagé — flyTo traverse tout le Bordelais
    // depuis la vue d'ensemble avant d'arriver. Plusieurs secondes ajoutées au
    // démarrage de l'application, pour une animation que personne n'a
    // demandée : le visiteur voulait ce château-là. Les vols suivants, eux,
    // disent d'où l'on vient et se gardent.
    const cam = {
      center: ch.ll, zoom: 15.2, pitch: 55,
      bearing: (map.getBearing() + 22) % 360,
    };
    if (premierCadrageRef.current) {
      premierCadrageRef.current = false;
      map.jumpTo(cam);
    } else {
      map.flyTo({ ...cam, speed: 0.85, curve: 1.5 });
    }

    // Immediately clear previous parcels so the old château's layer
    // disappears the moment we start flying to the new one.
    const srcNow = map.getSource("parcels");
    if (srcNow) srcNow.setData({ type:"FeatureCollection", features:[] });

    // load parcels (only if this is still the active one when fetch resolves)
    fetch(`parcels/${PARCEL_ALIAS[ch.c] || ch.c}.geojson`)
      .then(r => r.ok ? r.json() : null)
      .then(gj => {
        if (!gj || activeIdRef.current !== ch.c) return;
        // normalise: some files miss top-level "type" and per-feature geometry.type
        if (!gj.type) gj.type = "FeatureCollection";
        if (Array.isArray(gj.features)) {
          gj.features.forEach(f => {
            if (!f.type) f.type = "Feature";
            if (!f.properties) f.properties = {};
            const g = f.geometry;
            if (g && !g.type && Array.isArray(g.coordinates)) {
              // infer Polygon vs MultiPolygon from nesting depth
              let depth = 0, n = g.coordinates;
              while (Array.isArray(n)) { depth++; n = n[0]; }
              // Polygon: [[[lng,lat],…]] -> depth 3 ; MultiPolygon: [[[[…]]]] -> 4
              g.type = depth >= 4 ? "MultiPolygon" : "Polygon";
            }
          });
        }
        // Cache raw + chateau context so a mode change can recolor
        // without re-fetching from disk.
        parcelsRef.current = gj;
        parcelChateauRef.current = ch;
        applyParcelColors(gj, ch, modeRef.current, yearRef.current, map);
        const src = map.getSource("parcels");
        if (src) src.setData(gj);
        // A cépage hatch pattern registered by applyParcelColors just now
        // (map.addImage) can land in the sprite atlas one tick after this
        // setData's render pass, so the hatch never appears until some
        // later interaction repaints the map. Force that repaint now.
        map.triggerRepaint();
      })
      .catch(()=>{});
  }, [activeId]);

  // ---- hover ring on markers ----
  React.useEffect(() => {
    Object.entries(markersRef.current).forEach(([id, m]) => {
      m.getElement().classList.toggle("hovered", id === hoveredId);
    });
  }, [hoveredId]);

  // ---- relief : suit la région affichée ----
  // Changer de pays change l'exagération ET l'équidistance des courbes
  // (voir RELIEF_BY_COUNTRY) : les deux sont réappliquées.
  React.useEffect(() => {
    countryRef.current = country;
    if (!mapRef.current || !readyRef.current) return;
    applyTerrain(true);
    rebuildContours();
  }, [country, applyTerrain, rebuildContours]);

  // ---- recolor parcels when displayMode OR vintage year changes ----
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map) return;
    const gj = parcelsRef.current;
    const ch = parcelChateauRef.current;
    if (!gj || !ch) return;
    applyParcelColors(gj, ch, displayMode || "cepages", year, map);
    const src = map.getSource("parcels");
    if (src) src.setData(gj);
    map.triggerRepaint();
  }, [displayMode, year]);

  return <div ref={containerRef} className="map-stage" data-zoom="mid" />;
});

window.MapStage = MapStage;
