// Континентальный атлас 2026 — маршрут турнира
// Single-page React app — relies on globals: React, ReactDOM, d3, topojson,
// window.HOST_CITIES, window.ROUTE_ORDER, window.QUIET_CITIES

(function () {
  const { useState, useEffect, useRef, useMemo, useCallback } = React;

  // ============================================================
  // Constants & sizing
  // ============================================================
  const W = 1920;
  const H = 1080;

  // ============================================================
  // Projection
  // ============================================================
  function makeProjection() {
    const proj = d3.geoAlbers().
    rotate([100, 0]).
    center([0, 39]).
    parallels([25, 55]).
    scale(1080).
    translate([W / 2, H / 2 + 10]);
    return proj;
  }

  // ============================================================
  // Smooth Catmull-Rom curve through points → SVG path
  // ============================================================
  function smoothCurvePath(pts, tension = 0.5) {
    if (pts.length < 2) return "";
    const p = pts;
    let d = `M${p[0][0].toFixed(2)} ${p[0][1].toFixed(2)}`;
    for (let i = 0; i < p.length - 1; i++) {
      const p0 = p[i - 1] || p[i];
      const p1 = p[i];
      const p2 = p[i + 1];
      const p3 = p[i + 2] || p2;
      const c1x = p1[0] + (p2[0] - p0[0]) / 6 * tension * 2;
      const c1y = p1[1] + (p2[1] - p0[1]) / 6 * tension * 2;
      const c2x = p2[0] - (p3[0] - p1[0]) / 6 * tension * 2;
      const c2y = p2[1] - (p3[1] - p1[1]) / 6 * tension * 2;
      d += `C${c1x.toFixed(2)} ${c1y.toFixed(2)},${c2x.toFixed(2)} ${c2y.toFixed(2)},${p2[0].toFixed(2)} ${p2[1].toFixed(2)}`;
    }
    return d;
  }

  // ============================================================
  // Tween helper
  // ============================================================
  const easeInOutCubic = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
  function tween({ from, to, duration = 1100, ease = easeInOutCubic, onUpdate, onDone }) {
    const start = performance.now();
    let raf;
    const step = (now) => {
      const t = Math.min(1, (now - start) / duration);
      const e = ease(t);
      const cur = {};
      for (const k in from) cur[k] = from[k] + (to[k] - from[k]) * e;
      onUpdate(cur);
      if (t < 1) raf = requestAnimationFrame(step);else if (onDone) onDone();
    };
    raf = requestAnimationFrame(step);
    return () => cancelAnimationFrame(raf);
  }

  // ============================================================
  // Graticule grid (cartographic lat/lon lines)
  // ============================================================
  function GraticuleLayer({ projection }) {
    const pathGen = useMemo(() => d3.geoPath(projection), [projection]);

    const major = useMemo(() => {
      const g = d3.geoGraticule().step([10, 10]);
      return pathGen(g());
    }, [pathGen]);

    const minor = useMemo(() => {
      const g = d3.geoGraticule().stepMinor([5, 5]).stepMajor([90, 90]);
      return pathGen(g.lines());
    }, [pathGen]);

    // Labels for major meridians/parallels — drawn just inside the visible canvas
    const meridianLabels = useMemo(() => {
      const out = [];
      // через одну меридиану — ровный шаг 40°
      for (let lon = -140; lon <= -60; lon += 40) {
        const p = projection([lon, 18]);
        if (p) out.push({ text: `${Math.abs(lon)}°З`, x: p[0], y: p[1] });
      }
      return out;
    }, [projection]);

    const parallelLabels = useMemo(() => {
      const out = [];
      // только северные параллели, не загромождая западные города
      for (let lat = 50; lat <= 60; lat += 10) {
        const p = projection([-128, lat]);
        if (p) out.push({ text: `${lat}°С`, x: p[0], y: p[1] });
      }
      return out;
    }, [projection]);

    return (
      <g style={{ pointerEvents: "none" }}>
        <path d={minor} fill="none" stroke="white" strokeOpacity="0.22" strokeWidth="0.5" />
        <path d={major} fill="none" stroke="white" strokeOpacity="0.5" strokeWidth="0.7" strokeDasharray="2 4" />
        {meridianLabels.map((l, i) =>
        <text key={"m" + i} x={l.x} y={l.y}
        fontSize="9" fontFamily="'Gotham Pro', sans-serif" fontWeight="500"
        fill="white" textAnchor="middle" letterSpacing="0.1em" opacity="0.7">
            {l.text}
          </text>
        )}
        {parallelLabels.map((l, i) =>
        <text key={"p" + i} x={l.x} y={l.y}
        fontSize="9" fontFamily="'Gotham Pro', sans-serif" fontWeight="500"
        fill="white" textAnchor="end" dx="-4" dy="3" letterSpacing="0.1em" opacity="0.7">
            {l.text}
          </text>
        )}
      </g>);

  }

  // ============================================================
  // Compass rose (роза ветров)
  // ============================================================
  const KRAKEN_PATHS = [
    "M421.993,198.286L342.54,25.24c-4.234-9.234-12.484-15.953-22.375-18.234c-9.891-2.266-20.266,0.156-28.109,6.609L144.915,134.489c-6.109,5.016-8.594,13.219-6.328,20.797c2.281,7.578,8.859,13.047,16.719,13.875l29.703,3.156l-42.891,101.469c-2.266,5.344-2.031,11.406,0.609,16.578c1.25,2.484,8.688,9.406,16.719,16.094c-8.375,21.125-27.469,74.609-12.406,89.063c42.672,40.906,184.953,37.344,186.719-7.109l-1.375-42.75c9.297-1.344,20.516-4.016,23.438-6.234c4.625-3.484,7.469-8.844,7.781-14.641l5.797-110.016l28.078,10.156c7.453,2.688,15.766,0.656,21.125-5.156C423.961,213.942,425.305,205.474,421.993,198.286z M172.274,381.724c-7.469,0-13.531-6.047-13.531-13.516c0-7.453,6.063-13.5,13.531-13.5c7.453,0,13.5,6.047,13.5,13.5C185.774,375.677,179.727,381.724,172.274,381.724z M288.805,398.505c-7.453,0-13.516-6.047-13.516-13.5c0-7.469,6.063-13.516,13.516-13.516c7.469,0,13.516,6.047,13.516,13.516C302.321,392.458,296.274,398.505,288.805,398.505z",
    "M73.258,164.661c19,26.594,11.672,60.656-24.078,108.984C2.29,337.005-2.804,395.302,1.008,421.911c3.797,26.625,62.109,20.281,64.641,2.547c4.297-30.219-22.672-47.359,24.094-145.75c36.734-77.313,7.578-117.859-2.547-128C77.071,140.583,64.368,155.786,73.258,164.661z",
    "M504.242,251.802c-13.891-30.578-40.297-30.578-23.609,5.563c31.953,59.766-77.843,76.438-72.281,148.719c1.063,13.922,51.203,18.969,56.984,2.781c6.953-19.469-4.172-26.406,23.641-58.375C513.523,322.271,518.164,282.38,504.242,251.802z",
    "M372.211,401.567c4.172,41.703-38.766,64.438-38.047,88.609c0.531,17.719,39.094,21.891,44.828,4.688c14.563-40.313,15.156-77.297,8.344-95.906C381.461,382.927,370.274,382.271,372.211,401.567z"
  ];

  function CompassRose({ x, y, size = 110 }) {
    const [open, setOpen] = useState(false);
    const r = size / 2;
    const labels = [
    { t: "С", a: 0 },
    { t: "В", a: 90 },
    { t: "Ю", a: 180 },
    { t: "З", a: 270 }];

    const sub = [45, 135, 225, 315];

    // 8-point star path
    const star = (rOuter, rInner, points = 8) => {
      let d = "";
      for (let i = 0; i < points * 2; i++) {
        const ang = i * Math.PI / points;
        const rad = i % 2 === 0 ? rOuter : rInner;
        const px = Math.sin(ang) * rad;
        const py = -Math.cos(ang) * rad;
        d += (i === 0 ? "M" : "L") + px.toFixed(2) + " " + py.toFixed(2);
      }
      return d + "Z";
    };

    return (
      <g className={"compass-rose" + (open ? " is-open" : "")} transform={`translate(${x} ${y})`} onClick={() => setOpen((v) => !v)}>
        {/* hover hit area */}
        <circle r={r * 1.05} fill="transparent" />

        {/* outer dotted ring */}
        <circle r={r} fill="none" stroke="white" strokeOpacity="0.8" strokeWidth="0.6" strokeDasharray="1 3" style={{ pointerEvents: "none" }} />
        <circle r={r * 0.78} fill="none" stroke="white" strokeOpacity="0.6" strokeWidth="0.4" style={{ pointerEvents: "none" }} />

        {/* tick marks every 15° */}
        <g style={{ pointerEvents: "none" }}>
        {Array.from({ length: 24 }, (_, i) => {
          const a = i * 15 * Math.PI / 180;
          const r1 = r * 0.78;
          const r2 = i % 6 === 0 ? r * 0.62 : i % 2 === 0 ? r * 0.70 : r * 0.74;
          return (
            <line key={i}
            x1={Math.sin(a) * r1} y1={-Math.cos(a) * r1}
            x2={Math.sin(a) * r2} y2={-Math.cos(a) * r2}
            stroke="white" strokeOpacity="0.7" strokeWidth="0.5" />);

        })}
        </g>

        {/* rotating needle assembly — wiggles on hover */}
        <g className="compass-needle" style={{ pointerEvents: "none" }}>
          {/* secondary star (NE/SE/SW/NW) */}
          <g opacity="0.6">
            {sub.map((a, i) =>
            <g key={i} transform={`rotate(${a})`}>
                <path d={`M0 0 L${r * 0.04} ${-r * 0.4} L0 ${-r * 0.55} L${-r * 0.04} ${-r * 0.4} Z`}
              fill="white" />
              </g>
            )}
          </g>

          {/* primary 4-point star */}
          <g>
            {labels.map((l, i) =>
            <g key={i} transform={`rotate(${l.a})`}>
                <path d={`M0 0 L${r * 0.06} ${-r * 0.55} L0 ${-r * 0.72} L${-r * 0.06} ${-r * 0.55} Z`}
              fill="white" />
              </g>
            )}
          </g>

          {/* center cap */}
          <circle r={2.4} fill="white" />
          <circle r={0.9} fill="var(--ocean)" />
        </g>

        {/* cardinal labels */}
        <g style={{ pointerEvents: "none" }}>
        {labels.map((l, i) => {
          const a = l.a * Math.PI / 180;
          const lr = r * 0.92;
          return (
            <text key={i}
            x={Math.sin(a) * lr}
            y={-Math.cos(a) * lr}
            fontSize="11"
            fontWeight="700"
            fontFamily="'Gotham Pro', sans-serif"
            fill="var(--plate-fill)"
            textAnchor="middle"
            dominantBaseline="central"
            letterSpacing="0.04em">
              {l.t}
            </text>);

        })}
        </g>

        {/* title strip */}
        <text y={r + 18}
        fontSize="8.5" fontWeight="500"
        fontFamily="'Gotham Pro', sans-serif"
        fill="white"
        textAnchor="middle"
        letterSpacing="0.32em"
        style={{ pointerEvents: "none" }}>
          РОЗА ВЕТРОВ
        </text>

        {/* decorative sea monster — emerges near the compass on hover, with delay */}
        <g transform={`translate(${r + 30} ${-r - 32}) scale(0.067)`} style={{ pointerEvents: "none" }}>
          <g className="sea-monster">
            {KRAKEN_PATHS.map((d, i) =>
            <path key={i} d={d} fill="var(--compass-fill)" />
            )}
          </g>
        </g>
        {/* ripple rings on the water after the monster surfaces */}
        <g style={{ pointerEvents: "none" }}>
          <circle className="ripple ripple-1" cx={r + 47} cy={-r + 4} r="7" fill="none" stroke="var(--compass-fill)" strokeWidth="0.9" />
          <circle className="ripple ripple-2" cx={r + 47} cy={-r + 4} r="7" fill="none" stroke="var(--compass-fill)" strokeWidth="0.9" />
        </g>
      </g>);

  }

  // ============================================================
  // Terrain — country silhouettes & borders only (no contours/hydro)
  // ============================================================
  function TerrainLayer({ projection, world, hoverCountry, setHoverCountry, alwaysBorders }) {
    const pathGen = useMemo(() => d3.geoPath(projection), [projection]);
    if (!world) return null;

    const countries = topojson.feature(world, world.objects.countries);
    const targetIds = new Set(["840", "124", "484"]); // US, CA, MX
    const naCountries = countries.features.filter((f) => targetIds.has(String(f.id)));

    // Inter-country borders (shared boundaries between US-CA, US-MX)
    const interior = topojson.mesh(
      world,
      world.objects.countries,
      (a, b) => a !== b && targetIds.has(String(a.id)) && targetIds.has(String(b.id))
    );
    const exterior = topojson.mesh(
      world,
      world.objects.countries,
      (a, b) => a === b && targetIds.has(String(a.id))
    );

    return (
      <g>
        {/* Ocean mask — surf only shows on the water side (visible in the ocean) */}
        <defs>
          <mask id="ocean-mask">
            <rect x="-4000" y="-4000" width="8000" height="8000" fill="white" />
            {naCountries.map((f) =>
            <path key={f.id} d={pathGen(f)} fill="black" />
            )}
          </mask>
        </defs>

        {/* Filled landmass */}
        <g>
          {naCountries.map((f) =>
          <path
            key={f.id}
            className="country"
            d={pathGen(f)}
            fill={hoverCountry === String(f.id) ? "var(--hover-fill)" : "var(--paper-soft)"}
            stroke="none"
            onMouseEnter={() => setHoverCountry(String(f.id))}
            onMouseLeave={() => setHoverCountry(null)} />

          )}
        </g>

        {/* Surf — masked to ocean so the wave glows out into the water */}
        <g mask="url(#ocean-mask)" style={{ pointerEvents: "none" }}>
          <path d={pathGen(exterior)} className="surf surf-2" fill="none" stroke="var(--surf-stroke)" strokeWidth="9" strokeOpacity="0" strokeLinejoin="round" />
          <path d={pathGen(exterior)} className="surf surf-1" fill="none" stroke="var(--surf-stroke)" strokeWidth="5" strokeOpacity="0" strokeLinejoin="round" />
        </g>

        {/* Coastline — extended off-canvas for seamless edges */}
        <path d={pathGen(exterior)} fill="none" stroke="var(--ink)" strokeWidth="1.0" strokeOpacity="0.9" style={{ pointerEvents: "none" }} />
        {/* Small hand-finished Gulf/Yucatan seam: source geometry reads visually broken at this scale. */}
        <path
          d="M1222 783 C1248 802,1277 831,1303 864 C1328 896,1342 922,1360 950"
          fill="none"
          stroke="var(--ink)"
          strokeWidth="1.05"
          strokeOpacity="0.9"
          strokeLinecap="round"
          strokeLinejoin="round"
          style={{ pointerEvents: "none" }}
        />
        {/* Extend coast beyond viewport with duplicate stroked rectangle */}
        <rect x="-4000" y="-4000" width="8000" height="8000" fill="none" stroke="var(--ink)" strokeWidth="1.0" strokeOpacity="0.9" style={{ pointerEvents: "none" }} />

        {/* Interior country borders — dashed */}
        <path d={pathGen(interior)} fill="none" stroke="var(--forest)" strokeWidth="0.7" strokeOpacity="0.7"
        strokeDasharray="5 3" style={{ pointerEvents: "none" }} />
      </g>);

  }

  // ============================================================
  // Hydrography — major rivers & lakes (ocean-colored)
  // ============================================================
  function HydroLayer({ projection, rivers, lakes }) {
    const pathGen = useMemo(() => d3.geoPath(projection), [projection]);
    if (!rivers && !lakes) return null;
    return (
      <g style={{ pointerEvents: "none" }}>
        {lakes &&
        <path d={pathGen(lakes)} fill="var(--ocean)" stroke="var(--ocean)" strokeWidth="0.5" />
        }
        {rivers &&
        <path d={pathGen(rivers)} fill="none" stroke="var(--ocean)" strokeWidth="1.1" strokeOpacity="0.95"
          strokeLinejoin="round" strokeLinecap="round" />
        }
      </g>);

  }

  // ============================================================
  // Markers — clean radar-ping pulse
  // ============================================================
  function QuietDots({ projection }) {
    return (
      <g style={{ pointerEvents: "none" }}>
        {window.QUIET_CITIES.map((c, i) => {
          const p = projection([c.lon, c.lat]);
          if (!p) return null;
          return (
            <g key={i} transform={`translate(${p[0]} ${p[1]})`}>
              <circle r="1.4" fill="var(--forest)" opacity="0.45" />
            </g>);

        })}
      </g>);

  }

  function HostMarker({ city, projection, idx, active, onHover, onLeave, onSelect, onZoom }) {
    const p = projection([city.lon, city.lat]);
    if (!p) return null;
    const [x, y] = p;
    const isFinal = city.id === "nyc";
    const isOpener = city.id === "mex";

    const off = labelOffset(city);

    // Адаптивный размер плашки на основе длины названия
    const cityNameLength = city.name.length;
    const boxWidth = Math.max(104, cityNameLength * 9.6 + 28);
    const boxHeight = 38;

    // Плашка крепится ближней стороной на фиксированном расстоянии от маркера
    const gap = 16;
    let labelCenterX;
    if (off.anchor === "start") labelCenterX = gap + boxWidth / 2;else
    if (off.anchor === "end") labelCenterX = -(gap + boxWidth / 2);else
    labelCenterX = 0;
    const labelCenterY = off.y;
    const boxX = labelCenterX - boxWidth / 2;
    const boxY = labelCenterY - boxHeight / 2;

    return (
      <g transform={`translate(${x} ${y})`} style={{ cursor: "pointer" }}
      onMouseEnter={() => onHover(city, x + labelCenterX, y + labelCenterY)}
      onMouseLeave={onLeave}
      onClick={(e) => { e.stopPropagation(); onSelect(city, x + labelCenterX, y + labelCenterY); }}
      onDoubleClick={(e) => { e.stopPropagation(); onZoom(city); }}>

        {/* label group — plate + name, hover-reactive */}
        <g className="city-label">
          <rect
            className="city-plate"
            x={boxX}
            y={boxY}
            width={boxWidth}
            height={boxHeight}
            rx="8"
            fill="white"
            stroke="var(--ink)"
            strokeWidth="1" />
          <text
            x={labelCenterX}
            y={labelCenterY}
            fontSize="13"
            fontFamily="'Gotham Pro', sans-serif"
            fontWeight="700"
            fill="var(--label-fill)"
            letterSpacing="0.04em"
            textAnchor="middle"
            dominantBaseline="central"
            style={{ userSelect: "none", textTransform: "uppercase" }}>
            {city.name.toUpperCase()}
          </text>
        </g>

        {/* three radar pings — staggered */}
        <circle r="9" fill="none" stroke="var(--accent)" strokeWidth="1" className="marker-ping" />
        <circle r="9" fill="none" stroke="var(--accent)" strokeWidth="1" className="marker-ping delay-1" />
        <circle r="9" fill="none" stroke="var(--accent)" strokeWidth="1" className="marker-ping delay-2" />

        {/* solid outer ring */}
        <circle r="8.5" fill="none" stroke="var(--accent)" strokeWidth="0.7" strokeOpacity="0.5" />

        {/* inner dot — обводка цвета маршрута */}
        <circle r={isFinal || isOpener ? 5 : 4.2} fill="var(--accent)" stroke="var(--rust)" strokeWidth="1.4" className="marker-core" />
        <circle r={isFinal || isOpener ? 2 : 1.6} fill={active ? "var(--ink)" : "var(--paper)"} />

        {/* hit area */}
        <circle r="22" fill="transparent" />
      </g>);

  }

  function labelOffset(city) {
    const m = {
      van: { dx: -68, dy: -16, a: "end" }, // Ванкувер — слева, выше
      sea: { dx: -68, dy: 22, a: "end" }, // Сиэтл — слева, ниже
      sfo: { dx: -68, dy: 0, a: "end" }, // Сан-Франциско — слева
      lax: { dx: -68, dy: 0, a: "end" }, // Лос-Анджелес — слева
      gdl: { dx: -68, dy: -16, a: "end" }, // Гвадалахара — слева, выше
      mex: { dx: -68, dy: 22, a: "end" }, // Мехико — слева, ниже
      mty: { dx: 68, dy: 0, a: "start" }, // Монтеррей — справа
      hou: { dx: 68, dy: 12, a: "start" }, // Хьюстон — справа, ниже
      dal: { dx: -68, dy: 0, a: "end" }, // Даллас — слева
      kan: { dx: -68, dy: 0, a: "end" }, // Канзас-Сити — слева
      atl: { dx: 68, dy: 0, a: "start" }, // Атланта — справа
      mia: { dx: 68, dy: 0, a: "start" }, // Майами — справа
      phi: { dx: -68, dy: 14, a: "end" }, // Филадельфия — слева, ниже
      nyc: { dx: 68, dy: 8, a: "start" }, // Нью-Йорк — справа
      bos: { dx: 68, dy: -28, a: "start" }, // Бостон — справа, выше
      tor: { dx: -68, dy: -22, a: "end" } // Торонто — слева, выше
    };
    const v = m[city.id] || { dx: 68, dy: 0, a: "start" };
    return { x: v.dx, y: v.dy, anchor: v.a };
  }

  // ============================================================
  // Route — static line + faint march dashes
  // ============================================================
  function RouteLayer({ projection, weight = "medium" }) {
    const ordered = window.ROUTE_ORDER.map((id) => window.HOST_CITIES.find((c) => c.id === id));
    const pts = ordered.map((c) => projection([c.lon, c.lat]));
    const d = smoothCurvePath(pts, 0.55);

    const sw = weight === "thin" ? 0.9 : weight === "bold" ? 2.0 : 1.4;

    return (
      <g style={{ pointerEvents: "none" }}>
        {/* main line */}
        <path
          d={d}
          fill="none"
          stroke="var(--rust)"
          strokeWidth={sw}
          strokeOpacity="0.85"
          strokeLinecap="round"
          strokeLinejoin="round" />
        
        {/* faint march overlay */}
        <path
          d={d}
          fill="none"
          stroke="var(--ink)"
          strokeOpacity="0.4"
          strokeWidth="0.5"
          strokeDasharray="1 6"
          strokeLinecap="round"
          className="route-march" />
        
      </g>);

  }

  // ============================================================
  // Main app
  // ============================================================
  function App() {
    const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
      "showQuietCities": true,
      "showGraticule": true,
      "showCompass": true,
      "routeWeight": "medium",
      "ambientMotion": true
    } /*EDITMODE-END*/;
    const [t, setTweak] = window.useTweaks(TWEAK_DEFAULTS);

    const [world, setWorld] = useState(null);
    const [hoverCountry, setHoverCountry] = useState(null);
    const [hovered, setHovered] = useState(null);
    const [focused, setFocused] = useState(null);
    const [darkMode, setDarkMode] = useState(false);
    const [ready, setReady] = useState(false);
    const [loadProgress, setLoadProgress] = useState(5);
    const [parallax, setParallax] = useState({ x: 0, y: 0 });
    const [isMobile, setIsMobile] = useState(typeof window !== "undefined" && window.innerWidth < 768);
    const dismissTimerRef = useRef(null);

    useEffect(() => {
      const onResize = () => setIsMobile(window.innerWidth < 768);
      window.addEventListener("resize", onResize);
      return () => window.removeEventListener("resize", onResize);
    }, []);

    useEffect(() => {
      const onMessage = (event) => {
        if (event.data?.type === "SET_DARK_MODE") {
          setDarkMode(Boolean(event.data.value));
        }
      };
      window.addEventListener("message", onMessage);
      return () => window.removeEventListener("message", onMessage);
    }, []);

    const showCity = useCallback((city, sx, sy, locked = false) => {
      clearTimeout(dismissTimerRef.current);
      dismissTimerRef.current = null;
      const p = projection([city.lon, city.lat]);
      setHovered({ city, sx: sx != null ? sx : p[0], sy: sy != null ? sy : p[1], locked });
    }, []);
    const closeCity = useCallback(() => {
      clearTimeout(dismissTimerRef.current);
      dismissTimerRef.current = null;
      setHovered(null);
    }, []);
    const showNextCity = useCallback(() => {
      setHovered((current) => {
        const currentId = current?.city?.id;
        const index = Math.max(0, window.HOST_CITIES.findIndex((city) => city.id === currentId));
        const next = window.HOST_CITIES[(index + 1) % window.HOST_CITIES.length];
        const p = projection([next.lon, next.lat]);
        return { city: next, sx: p[0], sy: p[1], locked: true };
      });
    }, [projection]);
    const scheduleDismiss = useCallback(() => {
      clearTimeout(dismissTimerRef.current);
      dismissTimerRef.current = setTimeout(() => {
        setHovered((current) => current?.locked ? current : null);
      }, 280);
    }, []);
    const cancelDismiss = useCallback(() => {
      clearTimeout(dismissTimerRef.current);
      dismissTimerRef.current = null;
    }, []);
    const projection = useMemo(makeProjection, []);

    const viewRef = useRef({ cx: W / 2, cy: H / 2, scale: 1 });
    const [view, setView] = useState({ ...viewRef.current });
    const svgRef = useRef(null);
    const tweenCancelRef = useRef(null);

    const dragRef = useRef({ active: false, lastX: 0, lastY: 0, vx: 0, vy: 0, lastT: 0 });
    const inertiaRaf = useRef(null);

    useEffect(() => {
      let cancelled = false;
      fetch("https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json").
      then((r) => r.json()).
      then((j) => {if (!cancelled) setWorld(j);}).
      catch((e) => console.warn("world atlas fetch failed", e));
      return () => {cancelled = true;};
    }, []);

    useEffect(() => {
      const id = window.setInterval(() => {
        setLoadProgress((p) => ready ? 100 : Math.min(94, p + Math.ceil((96 - p) * 0.12)));
      }, 120);
      return () => window.clearInterval(id);
    }, [ready]);

    useEffect(() => {
      if (!world) return;
      setLoadProgress(100);
      const id = window.setTimeout(() => setReady(true), 520);
      return () => window.clearTimeout(id);
    }, [world]);

    const [rivers, setRivers] = useState(null);
    const [lakes, setLakes] = useState(null);
    useEffect(() => {
      let cancelled = false;
      fetch("https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_110m_rivers_lake_centerlines.geojson").
      then((r) => r.json()).
      then((j) => {if (!cancelled) setRivers(j);}).
      catch((e) => console.warn("rivers fetch failed", e));
      fetch("https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_110m_lakes.geojson").
      then((r) => r.json()).
      then((j) => {if (!cancelled) setLakes(j);}).
      catch((e) => console.warn("lakes fetch failed", e));
      return () => {cancelled = true;};
    }, []);

    const clampView = useCallback((v) => {
      const vw = W / v.scale, vh = H / v.scale;
      const looseMobile = typeof window !== "undefined" && window.innerWidth < 768;
      const mx = W * (looseMobile ? 0.34 : 0.05), my = H * (looseMobile ? 0.2 : 0.05);
      const minCx = vw / 2 - mx, maxCx = W + mx - vw / 2;
      const minCy = vh / 2 - my, maxCy = H + my - vh / 2;
      const cx = minCx > maxCx ? W / 2 : Math.max(minCx, Math.min(maxCx, v.cx));
      const cy = minCy > maxCy ? H / 2 : Math.max(minCy, Math.min(maxCy, v.cy));
      return { ...v, cx, cy };
    }, []);

    const applyView = useCallback((v) => {
      const cv = clampView(v);
      viewRef.current = cv;
      setView({ ...cv });
    }, [clampView]);

    const cancelTween = () => {
      if (tweenCancelRef.current) {tweenCancelRef.current();tweenCancelRef.current = null;}
      if (inertiaRaf.current) {cancelAnimationFrame(inertiaRaf.current);inertiaRaf.current = null;}
    };

    const goTo = useCallback((target, duration = 1200) => {
      cancelTween();
      tweenCancelRef.current = tween({
        from: { ...viewRef.current },
        to: target,
        duration,
        onUpdate: (cur) => applyView(cur),
        onDone: () => {tweenCancelRef.current = null;}
      });
    }, [applyView]);

    const zoomToCity = useCallback((city) => {
      const p = projection([city.lon, city.lat]);
      if (!p) return;
      setFocused(city.id);
      goTo({ cx: p[0], cy: p[1], scale: 2.6 }, 1300);
    }, [goTo, projection]);

    const resetView = useCallback(() => {
      setFocused(null);
      goTo({ cx: W / 2, cy: H / 2, scale: 1 }, 1400);
    }, [goTo]);

    const zoomToPoint = useCallback((e, duration = 900) => {
      const el = svgRef.current;
      if (!el) return;
      const rect = el.getBoundingClientRect();
      const v = viewRef.current;
      const fx = (e.clientX - rect.left) / rect.width;
      const fy = (e.clientY - rect.top) / rect.height;
      const vw = W / v.scale, vh = H / v.scale;
      const wx = v.cx - vw / 2 + fx * vw;
      const wy = v.cy - vh / 2 + fy * vh;
      setFocused(null);
      goTo({ cx: wx, cy: wy, scale: 2.35 }, duration);
    }, [goTo]);

    // Touch interaction: drag to pan, double-tap to zoom in/out (mobile only)
    useEffect(() => {
      const el = svgRef.current;
      if (!el) return;
      let active = false, lastX = 0, lastY = 0, lastTap = 0;
      const canMouseDrag = () => viewRef.current.scale > 1.4;
      const onDown = (e) => {
        const isTouch = e.pointerType === "touch";
        if (!isTouch && !canMouseDrag()) return; // mouse drag only when zoomed in
        if (e.target.closest("[data-marker]")) return;
        cancelTween();
        const now = performance.now();
        const v = viewRef.current;
        if (isTouch && now - lastTap < 320) {
          // double tap
          if (v.scale > 1.4) {
            resetView();
          } else {
            zoomToPoint(e, 900);
          }
          lastTap = 0;
          return;
        }
        lastTap = now;
        active = true;
        lastX = e.clientX;
        lastY = e.clientY;
        el.classList.add("grabbing");
      };
      const onMove = (e) => {
        if (!active) return;
        if (e.pointerType !== "touch" && !canMouseDrag()) return;
        const dx = e.clientX - lastX, dy = e.clientY - lastY;
        lastX = e.clientX;
        lastY = e.clientY;
        const rect = el.getBoundingClientRect();
        const v = viewRef.current;
        const sf = W / v.scale / rect.width;
        applyView({ ...v, cx: v.cx - dx * sf, cy: v.cy - dy * sf });
      };
      const onUp = () => { active = false; el.classList.remove("grabbing"); };
      el.addEventListener("pointerdown", onDown);
      window.addEventListener("pointermove", onMove);
      window.addEventListener("pointerup", onUp);
      window.addEventListener("pointercancel", onUp);
      return () => {
        el.removeEventListener("pointerdown", onDown);
        window.removeEventListener("pointermove", onMove);
        window.removeEventListener("pointerup", onUp);
        window.removeEventListener("pointercancel", onUp);
      };
    }, [applyView, resetView, goTo, zoomToPoint]);

    const vbw = W / view.scale;
    const vbh = H / view.scale;
    const vbx = view.cx - vbw / 2;
    const vby = view.cy - vbh / 2;
    const viewBox = `${vbx} ${vby} ${vbw} ${vbh}`;

    const cardScreen = useMemo(() => {
      if (!hovered || !svgRef.current) return null;
      const rect = svgRef.current.getBoundingClientRect();
      const fx = (hovered.sx - vbx) / vbw;
      const fy = (hovered.sy - vby) / vbh;
      return { x: rect.left + fx * rect.width, y: rect.top + fy * rect.height };
    }, [hovered, vbx, vby, vbw, vbh, parallax]);

    const onBoxMove = (e) => {
      if (dragRef.current.active || view.scale > 1.4) {setParallax({ x: 0, y: 0 });return;}
      const rect = e.currentTarget.getBoundingClientRect();
      const fx = (e.clientX - rect.left) / rect.width - 0.5;
      const fy = (e.clientY - rect.top) / rect.height - 0.5;
      setParallax({ x: -fx * 16, y: -fy * 10 });
    };
    const onBoxLeave = () => setParallax({ x: 0, y: 0 });

    return (
      <div className={"atlas-root" + (ready ? " is-ready" : "") + (t.ambientMotion ? "" : " no-motion") + (darkMode ? " dark-mode" : "") + (hovered?.locked ? " card-open" : "")}>
        {!ready &&
        <div className="atlas-loader" aria-label="Загрузка карты">
          <div className="atlas-loader__panel">
            <div className="atlas-loader__kicker">JetTour × Матч ТВ</div>
            <div className="atlas-loader__line"><span style={{ width: `${loadProgress}%` }} /></div>
            <div className="atlas-loader__meta">
              <span>Картографический слой</span>
              <b>{loadProgress}%</b>
            </div>
          </div>
        </div>
        }
        <header className="chrome top-center">
          <div className="brand brand-pill" style={{
            height: "44px",
            paddingLeft: "24px",
            paddingRight: "24px",
            borderRadius: "28px",
            background: "linear-gradient(135deg, #76C0C5 0%, #5DACB1 55%, #4E9499 100%)",
            display: "flex",
            alignItems: "center",
            justifyContent: "center"
          }}>
            <div className="brand-title" style={{ fontSize: "14px", fontWeight: "700", textAlign: "center", letterSpacing: "0.03em", color: "rgb(255, 255, 255)" }}>JETOUR ОБЪЕДИНЯЕТ</div>
          </div>
          <div style={{ marginTop: "10px", display: "flex", justifyContent: "center" }}>
            <span style={{ padding: "5px 16px", background: "white", borderRadius: "999px", fontWeight: "600", color: "#1B2F34", letterSpacing: "0.18em", textTransform: "uppercase", fontSize: "8px" }}>16 городов · 1 маршрут</span>
          </div>
        </header>
        <div className="map-box" onMouseMove={onBoxMove} onMouseLeave={onBoxLeave}>
        <svg
            ref={svgRef}
            viewBox={viewBox}
            preserveAspectRatio={isMobile ? "xMidYMid slice" : "xMidYMid meet"}
            className={"atlas-svg" + (view.scale > 1.4 ? " zoomed" : "")}
            onDoubleClick={(e) => {
              if (e.target.closest("[data-marker]")) return;
              if (view.scale > 1.4) resetView(); else zoomToPoint(e, 900);
            }}
            style={{ transform: `scale(1.06) translate3d(${parallax.x}px, ${parallax.y}px, 0)` }}>

          <defs>
            <radialGradient id="ocean-grad" cx="50%" cy="42%" r="75%">
              <stop offset="0%" stopColor="var(--ocean-grad-1)" />
              <stop offset="55%" stopColor="var(--ocean-grad-2)" />
              <stop offset="100%" stopColor="var(--ocean-grad-3)" />
            </radialGradient>
          </defs>

          {/* Paper background */}
          <rect x="-3000" y="-3000" width="9000" height="9000" fill="var(--ocean)" />
          <rect x={vbx} y={vby} width={vbw} height={vbh} fill="url(#ocean-grad)" />

          {/* Graticule grid */}
          {t.showGraticule && <GraticuleLayer projection={projection} />}

          {/* Terrain (silhouettes + borders only) */}
          <TerrainLayer
              projection={projection}
              world={world}
              hoverCountry={hoverCountry}
              setHoverCountry={setHoverCountry}
              alwaysBorders={true} />
            
          {/* Rivers & lakes — ocean-colored hydrography */}
          <HydroLayer projection={projection} rivers={rivers} lakes={lakes} />

          {/* Quiet city dots */}
          {t.showQuietCities && <QuietDots projection={projection} />}

          {/* Route */}
          <RouteLayer projection={projection} weight={t.routeWeight} />

          {/* Host markers */}
          <g>
            {window.HOST_CITIES.map((c, i) =>
              <g key={c.id} data-marker>
                <HostMarker
                  city={c}
                  projection={projection}
                  idx={i}
                  active={focused === c.id || hovered?.city.id === c.id}
                  onHover={showCity}
                  onLeave={scheduleDismiss}
                  onSelect={(city, sx, sy) => showCity(city, sx, sy, true)}
                  onZoom={zoomToCity} />
                
              </g>
              )}
          </g>

          {/* Compass rose — over the Pacific, empty space */}
          {t.showCompass && <CompassRose x={projection([-145, 32])[0]} y={projection([-145, 32])[1]} size={120} />}
        </svg>
        </div>

        {/* Hover Card */}
        {hovered?.locked && <div className="atlas-backdrop" onClick={closeCity} />}
        {hovered && cardScreen &&
        <HoverCard
          city={hovered.city}
          x={cardScreen.x}
          y={cardScreen.y}
          side={cardScreen.x > window.innerWidth / 2 ? "right" : "left"}
          viewportWidth={window.innerWidth}
          onEnter={cancelDismiss}
          onLeave={scheduleDismiss}
          locked={hovered.locked}
          onClose={closeCity}
          onNext={showNextCity} />

        }

        {/* Chrome / HUD */}
        <Chrome
          focusedCity={focused ? window.HOST_CITIES.find((c) => c.id === focused) : null}
          onReset={resetView}
          scale={view.scale} />
        

        {/* Tweaks Panel */}
        <window.TweaksPanel title="Tweaks">
          <window.TweakSection label="Карта" />
          <window.TweakToggle
            label="Сетка координат"
            value={t.showGraticule}
            onChange={(v) => setTweak("showGraticule", v)} />
          
          <window.TweakToggle
            label="Города-миллионники"
            value={t.showQuietCities}
            onChange={(v) => setTweak("showQuietCities", v)} />
          
          <window.TweakToggle
            label="Роза ветров"
            value={t.showCompass}
            onChange={(v) => setTweak("showCompass", v)} />
          
          <window.TweakSection label="Маршрут" />
          <window.TweakRadio
            label="Толщина"
            value={t.routeWeight}
            options={["thin", "medium", "bold"]}
            onChange={(v) => setTweak("routeWeight", v)} />
          
          <window.TweakToggle
            label="Анимация"
            value={t.ambientMotion}
            onChange={(v) => setTweak("ambientMotion", v)} />
          
        </window.TweaksPanel>
      </div>);

  }

  // ============================================================
  // Hover card
  // ============================================================
  function HoverCard({ city, x, y, side, viewportWidth, onEnter, onLeave, locked, onClose, onNext }) {
    const matches = sampleMatches(city);
    const compact = viewportWidth < 768;
    const desktopCardWidth = Math.min(440, Math.max(320, viewportWidth - 72));
    const desktopLeft = side === "right"
      ? Math.max(24, viewportWidth - desktopCardWidth - 40)
      : 40;
    const cardStyle = compact
      ? { left: x, top: y }
      : { left: desktopLeft, top: "auto", bottom: 58, width: desktopCardWidth };
    return (
      <div
        className={"hover-card side-" + side + (locked ? " is-locked" : "")}
        style={cardStyle}
        key={city.id}
        onMouseEnter={onEnter}
        onMouseLeave={onLeave}>
        <div className="hover-card-inner">
          {locked &&
          <button className="hc-close" type="button" aria-label="Закрыть карточку города" onClick={onClose}>
            ×
          </button>
          }
          <div className="hc-eyebrow">
            <span className="hc-flag">{flagOf(city.country)}</span>
            <span className="hc-country">{country(city.country)}</span>
            <span className="hc-dot" />
            <span>{city.pop}</span>
          </div>
          <div className="hc-name">{city.name}</div>
          <div className="hc-dates">{city.dates}</div>
          <div className="hc-desc">{city.desc}</div>

          <div className="hc-matches">
            <div className="hc-matches-head">Матчи в городе</div>
            {matches.map((m, i) =>
            <div className="hc-match-row" key={i}>
                <div className="hc-match-date">{m.date}</div>
                <div className="hc-match-teams">
                  <span className="hc-team">
                    <span className="hc-cc">{m.a.c}</span>{m.a.n}
                  </span>
                  <span className="hc-vs">vs</span>
                  <span className="hc-team">
                    {m.b.n}<span className="hc-cc">{m.b.c}</span>
                  </span>
                </div>
                <button className="hc-predict">Сделать прогноз</button>
              </div>
            )}
          </div>

          <div className="hc-actions">
            <button className="hc-cta">
              <span>Играть в мини-игру</span>
            </button>
            {locked &&
            <button className="hc-next" type="button" onClick={onNext}>
              Следующий город
            </button>
            }
          </div>
        </div>
      </div>);

  }

  function flagOf(c) {return c === "US" ? "USA" : c === "CA" ? "CAN" : "MEX";}
  function country(c) {return c === "US" ? "Соединённые Штаты" : c === "CA" ? "Канада" : "Мексика";}

  // Плейсхолдерные матчи — до 3 игр на город (резерв под реальные данные)
  const TEAM_POOL = [
  { n: "Бразилия", c: "BRA" }, { n: "Аргентина", c: "ARG" }, { n: "Франция", c: "FRA" },
  { n: "Испания", c: "ESP" }, { n: "Германия", c: "GER" }, { n: "Англия", c: "ENG" },
  { n: "Португалия", c: "POR" }, { n: "Нидерланды", c: "NED" }, { n: "Хорватия", c: "CRO" },
  { n: "Марокко", c: "MAR" }, { n: "Япония", c: "JPN" }, { n: "Мексика", c: "MEX" }];

  function sampleMatches(city) {
    let seed = 0;
    for (const ch of city.id) seed += ch.charCodeAt(0);
    const baseDay = 14 + seed % 6;
    const out = [];
    for (let i = 0; i < 3; i++) {
      const a = TEAM_POOL[(seed + i * 3) % TEAM_POOL.length];
      const b = TEAM_POOL[(seed + i * 3 + 5) % TEAM_POOL.length];
      out.push({ date: `${baseDay + i * 4} ИЮН`, a, b });
    }
    return out;
  }

  // ============================================================
  // Chrome — title, legend, zoom indicator
  // ============================================================
  function Chrome({ focusedCity, onReset, scale }) {
    return (
      <>


        <div className="chrome bottom-right">
          <div className="zoom-cluster">
            {scale > 1.4 &&
            <button className="zoom-btn active" onClick={onReset}>
              Континентальный вид
            </button>
            }
          </div>
        </div>

        <div className="chrome hint">
          <span style={{ letterSpacing: "1px", fontSize: "9px" }}>Двойной клик / тап — приближение</span>
        </div>
      </>);

  }

  // ============================================================
  // Mount
  // ============================================================
  const root = ReactDOM.createRoot(document.getElementById("root"));
  root.render(<App />);
})();
