/* ============================================================
   app.jsx — メインアプリ
   ============================================================ */

const DEFAULT_PARAMS = {
  depth: 50,
  tanaDepth: 30,
  tideSpeed: 0.35,
  tideDepthFactor: 0.5,

  peNo: 3,
  bishiNo: 80,
  // ビシ窓を上下分離（サニー商事 / TSURIMARU 王道準拠）
  // 上窓: しゃくり時の主放出口だが「ポロポロ程度」が標準＝30%以下
  // 下窓: 「オキアミ体幅」≒ 全閉に近い (連続漏れは控えめ)
  cageUpperOpening: 0.25,
  cageLowerOpening: 0,
  // コマセ粒サイズ: M/L/2L/3L (将来 アミエビ/イワシミンチ拡張用)
  komaseSize: "L",
  // 煙幕度: weak (オキアミ大粒) / medium (オキアミ+ アミ混合) / strong (アミ・ミンチ)
  smokeLevel: "weak",

  cushionLength: 1.0,
  // モトス (上の太ハリス・サル管の上): 通常 4-7号 フロロカーボン
  // デフォルト OFF (シンプル仕掛けが標準・必要に応じて有効化)
  motosEnabled: false,
  motosLength: 1.5,
  motosNo: 5,
  // サル管 (モトス-ハリス連結金具): 10-22号 程度。流体抵抗ほぼ無視
  // デフォルト OFF (モトスと併用)
  saruKanEnabled: false,
  saruKanSize: 14,
  // ハリス (下の細ハリス・サル管の下〜針): 通常 2-5号 フロロカーボン
  // 関東標準下限 8m (3-7m は警戒・参照: 釣楽/ORETSURI/SHIMANO)
  harrisLength: 8,
  harrisNo: 3,
  hookType: "madai",
  hookSize: 10,

  // ビシ落としこみ位置: 指示棚 +dropOffsetM が hook 初期目標 (default = タナ下 7m)
  // 実釣セオリー (SHIMANO/TSURI HACK): タナ下 ハリス長分 (6-10m) から始めて指示棚へ
  dropOffsetM: 7,

  ganDamaPos: "mid",
  ganDamaPct: 50,  // ガン玉位置: ビシ側(0)→針側(100) % で連続指定
  ganDamaSize: 0.2,

  // 関東コマセマダイ標準 (SHIMANO/TSURI HACK):
  //   タナ下 ハリス長分(7m) から大きく2回しゃくり → 指示棚へ → 食わせ待ち1-3分
  //   2回 × 3.5m = 7m 巻き上げ → dropOffsetM=7 から cage がタナに到達
  shakuriStrokeCm: 80,
  shakuriCountPerTrigger: 2,
  makiAmount: 3.5,
  dropAmount: 0.5,
  shakuriInterval: 60.0,
  autoShakuri: false,
  // 落とし込み速度はビシ号数から物理計算（dropSpeed は physicsParams で導出）
  // うねり (海況セクション)
  swellHeight: 0.5,
  swellPeriod: 6.0,
};

const LOCKABLE_PARAMS = [
  "peNo", "bishiNo",
  "cageUpperOpening", "cageLowerOpening",
  "komaseSize", "smokeLevel",
  "cushionLength",
  "motosEnabled", "motosLength", "motosNo",
  "saruKanEnabled", "saruKanSize",
  "harrisLength", "harrisNo",
  "hookType", "hookSize",
  "ganDamaPos", "ganDamaPct", "ganDamaSize",
  "shakuriStrokeCm", "shakuriCountPerTrigger",
  "makiAmount", "dropAmount", "shakuriInterval", "dropOffsetM",
];

const PRESETS = [
  // ── サイクル系 ──
  { key: "default",  label: "標準 3分", group: "cycle", patch: {} },
  { key: "kuripote", label: "渋い 4-5分", group: "cycle",
    patch: { harrisLength: 12, harrisNo: 2, hookType: "madai", hookSize: 8, ganDamaSize: 0,
             shakuriStrokeCm: 60, shakuriCountPerTrigger: 2,
             cageUpperOpening: 0.20, cageLowerOpening: 0,
             komaseSize: "L", smokeLevel: "weak",
             makiAmount: 2.5, shakuriInterval: 60 } },
  { key: "highact",  label: "高活性 2-3分", group: "cycle",
    patch: { shakuriStrokeCm: 75, shakuriCountPerTrigger: 3,
             cageUpperOpening: 0.30, cageLowerOpening: 0,
             makiAmount: 1.7, shakuriInterval: 20 } },

  // ── 海況系 ──
  { key: "calm",     label: "凪",     group: "cond", patch: {} },
  { key: "trendrun", label: "二枚潮", group: "cond",
    patch: { tideSpeed: 0.55, tideDepthFactor: -0.4, harrisNo: 3,
             ganDamaPos: "near-hook", ganDamaSize: 0.5, makiAmount: 1.7 } },
  { key: "fastide",  label: "速潮", group: "cond",
    patch: { tideSpeed: 0.8, tideDepthFactor: 0.6, bishiNo: 100, harrisLength: 8,
             ganDamaSize: 0.6, shakuriStrokeCm: 70, shakuriCountPerTrigger: 3,
             makiAmount: 1.7 } },

  // ── エリア系 ──
  // 東京湾・剣崎・久里浜: ビシ80, PE3, ハリス4号10m, ポロポロ放出
  { key: "tokyo_bay", label: "東京湾・剣崎", group: "area",
    patch: { depth: 60, tanaDepth: 35,
             bishiNo: 80, peNo: 3,
             cushionLength: 1.0, harrisLength: 10, harrisNo: 4,
             hookType: "madai", hookSize: 10, ganDamaSize: 0.2, ganDamaPos: "mid",
             cageUpperOpening: 0.25, cageLowerOpening: 0,
             komaseSize: "L", smokeLevel: "weak",
             shakuriStrokeCm: 80, shakuriCountPerTrigger: 2,
             makiAmount: 2.5, shakuriInterval: 60 } },
  { key: "sagami_std", label: "相模湾標準", group: "area",
    patch: { depth: 65, tanaDepth: 45,
             bishiNo: 60, peNo: 2,
             cushionLength: 1.0, harrisLength: 8, harrisNo: 3,
             hookType: "madai", hookSize: 10, ganDamaSize: 0.2, ganDamaPos: "mid",
             cageUpperOpening: 0.25, cageLowerOpening: 0,
             komaseSize: "L", smokeLevel: "weak",
             shakuriStrokeCm: 75, shakuriCountPerTrigger: 2,
             makiAmount: 2.0, shakuriInterval: 45 } },
  { key: "sagami_lt", label: "相模湾LT", group: "area",
    patch: { depth: 50, tanaDepth: 30,
             bishiNo: 40, peNo: 1.5,
             cushionLength: 1.0, harrisLength: 7, harrisNo: 2,
             hookType: "madai", hookSize: 9, ganDamaSize: 0.1, ganDamaPos: "mid",
             cageUpperOpening: 0.20, cageLowerOpening: 0,
             komaseSize: "L", smokeLevel: "weak",
             shakuriStrokeCm: 70, shakuriCountPerTrigger: 2,
             makiAmount: 1.7, shakuriInterval: 30 } },
  { key: "sagami_deep", label: "相模湾深場(冬)", group: "area",
    patch: { depth: 90, tanaDepth: 70,
             bishiNo: 100, peNo: 3,
             cushionLength: 1.5, harrisLength: 10, harrisNo: 4,
             hookType: "madai", hookSize: 11, ganDamaSize: 0.5, ganDamaPos: "mid",
             cageUpperOpening: 0.25, cageLowerOpening: 0,
             komaseSize: "L", smokeLevel: "weak",
             shakuriStrokeCm: 80, shakuriCountPerTrigger: 2,
             makiAmount: 2.0, shakuriInterval: 90 } },
  { key: "kamoshi",  label: "外房カモシ", group: "area",
    patch: { depth: 55, tanaDepth: 25,
             bishiNo: 100, peNo: 4,
             cushionLength: 1.5, harrisLength: 10, harrisNo: 4,
             hookType: "madai", hookSize: 11, ganDamaSize: 0.3, ganDamaPos: "mid",
             cageUpperOpening: 0.15, cageLowerOpening: 0,
             komaseSize: "M", smokeLevel: "strong",
             shakuriStrokeCm: 100, shakuriCountPerTrigger: 2,
             makiAmount: 2.5, shakuriInterval: 60 } },
];

const MAX_PARTICLES = 1500;

function App() {
  // T-7/T-10: 初回訪問時はかんたんモード + 自動しゃくり ON をデフォルトに
  const _isFirstVisit = (() => {
    try { return !localStorage.getItem("komase.visited"); } catch(e) { return true; }
  })();
  // T40: 初期プリセット選択（preset:on + 標準 3分 + 凪 + 東京湾・剣崎）
  const _initialSel = { preset: "on", cycle: "default", cond: "calm", area: "tokyo_bay" };
  const _initialParams = (() => {
    let p = { ...DEFAULT_PARAMS };
    for (const g of ["area", "cond", "cycle"]) {
      const key = _initialSel[g];
      if (key) {
        const preset = PRESETS.find(x => x.key === key);
        if (preset) p = { ...p, ...preset.patch };
      }
    }
    return _isFirstVisit ? { ...p, autoShakuri: true } : p;
  })();
  const [params, setParams] = useState(_initialParams);
  // T40: preset 行を追加した selectedPresets（preset: "on"/"off"）
  const [selectedPresets, setSelectedPresets] = useState(_initialSel);
  const [running, setRunning] = useState(true);
  const [speedMul, setSpeedMul] = useState(1);  // 1x / 2x / 3x シミュレーション速度
  const [tick, setTick] = useState(0);
  const [optimizing, setOptimizing] = useState(false);
  const [recommendation, setRecommendation] = useState(null);
  const [locks, setLocks] = useState({});
  const [drawerOpen, setDrawerOpen] = useState(false);
  const [drawerTab, setDrawerTab] = useState(0);

  // T40: 「有り」復帰用に直前選択を記憶
  const lastAppliedComboRef = useRef({ cycle: "default", cond: "calm", area: "tokyo_bay" });

  const canvasRef = useRef(null);
  const particlesRef = useRef([]);
  const rigStateRef = useRef({
    shakuriOffsetY: 0, shakuriVelY: 0, shakuriOffsetX: SimPhysics.ROD_X_M,
    makiOffset: 0, makiTarget: 0,
  });
  const chumRef = useRef(1.0);
  const heatmapRef = useRef(null);
  const lastTimeRef = useRef(0);
  const accumRef = useRef(0);
  const shakuriTimerRef = useRef(0);
  const flashRef = useRef(0);
  const pendingMakiRef = useRef([]);
  const pendingShakuriRef = useRef([]);
  const lastShakuriAtRef = useRef(0);
  // 自動最適動作の状態マシン: "shakuri" | "biting" | "dropping" | "idle"
  //   shakuri: N発撃ち中 → 撃ち終わって maki が落ち着いたら biting
  //   biting: 高位置 (tana - makiAmount) で食わせ待ち (shakuriInterval 秒)
  //   dropping: タナへ戻る (makiTarget=0) → 着いたら shakuri 再開
  const autoStateRef = useRef({ state: "idle", biteTimer: 0 });
  const phaseRef = useRef("fishing");
  const bishiAbsYRef = useRef(0);
  const dropVelRef = useRef(0);
  const swellPhaseRef = useRef(0);
  const swellOffsetYRef = useRef(0);
  const minimapPosRef = useRef(null);
  const minimapDragRef = useRef(null);
  const [bowViewSide, setBowViewSide] = useState("port");
  const bowViewSideRef = useRef("port");
  bowViewSideRef.current = bowViewSide;
  const [phase, setPhase] = useState("fishing");
  const [toast, setToast] = useState(null);
  const toastTimerRef = useRef(null);
  const showToast = (text, color) => {
    if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
    setToast({ text, color: color || "var(--brass)" });
    toastTimerRef.current = setTimeout(() => setToast(null), 2200);
  };
  const tanaArrivalLastRef = useRef(false);

  // ===== 1サイクル評価 (投入→仕掛け回収 までの集計) =====
  //   実釣感: 投入してから回収するまでの 1 往復で「うまく釣れる配置を維持できたか」を採点
  //   フレームベースで sync/cage位置/hook位置を集計し、回収時にスコア確定
  const cycleStatsRef = useRef({ goodFrames: 0, okFrames: 0, totalFrames: 0, peakSync: 0, sumSync: 0, simElapsed: 0 });
  const [lastCycleResult, setLastCycleResult] = useState(null);  // { score, grade, note, sustainRate, meanSync, durationSec, cycleNo }
  const cycleCountRef = useRef(0);

  const toggleLock = (name) => {
    setLocks(prev => ({ ...prev, [name]: !prev[name] }));
  };

  const metricsRef = useRef({
    hitCount: 0,
    sampleCount: 0,
    hitRateEMA: 0,
    shakuriCount: 0,
    totalSpawned: 0,
    hookDepth: 0,
    harrisAngleDeg: 0,
    histogram: new Array(24).fill(0),
    hookBin: 0,
  });

  // 物理に渡す前に hookWeight・dropSpeed を算出して合成
  const physicsParams = useMemo(() => ({
    ...params,
    hookWeight: SimPhysics.getHookWeight(params.hookType, params.hookSize),
    dropSpeed: SimPhysics.computeDropSpeed(params.bishiNo),
  }), [params]);
  const physicsParamsRef = useRef(physicsParams);
  physicsParamsRef.current = physicsParams;

  // パッチ合成: DEFAULT → area → cond → cycle の順で上書き
  // (area = 環境ベース / cond = 海況上書き / cycle = 動作上書き)
  function rebuildFromPresets(sel) {
    let p = { ...DEFAULT_PARAMS };
    for (const g of ["area", "cond", "cycle"]) {
      const key = sel[g];
      if (key) {
        const preset = PRESETS.find(x => x.key === key);
        if (preset) p = { ...p, ...preset.patch };
      }
    }
    return p;
  }

  const set = (patch) => {
    setParams(prev => ({ ...prev, ...patch }));
    // T40: 手動編集時は preset 行も含め全プリセット解除
    setSelectedPresets({ preset: "off", cycle: null, cond: null, area: null });
  };

  // T40: プリセット トグル拡張
  //   "preset:on"  → lastAppliedComboRef から area/cond/cycle を復元
  //   "preset:off" → preset="off" にして params は維持
  //   通常 key     → 既存ロジック維持 + preset:"on" を同時設定 + lastAppliedComboRef 更新
  const togglePreset = (key) => {
    if (key === "preset:off") {
      setSelectedPresets({ preset: "off", cycle: null, cond: null, area: null });
      showToast("プリセット解除", "var(--vermilion)");
      return;
    }
    if (key === "preset:on") {
      const _fallback = { cycle: "default", cond: "calm", area: "tokyo_bay" };
      const restored = { ...(_fallback), ...lastAppliedComboRef.current };
      const newSel = { preset: "on", ...restored };
      setSelectedPresets(newSel);
      setParams(rebuildFromPresets(newSel));
      resetSim();
      const active = ["area","cond","cycle"].map(gg => {
        const k = newSel[gg];
        const pp = k ? PRESETS.find(x => x.key === k) : null;
        return pp ? pp.label : null;
      }).filter(Boolean);
      showToast("プリセット復元: " + (active.length ? active.join(" + ") : "初期設定"), "var(--vermilion)");
      return;
    }
    // 通常 preset (area/cond/cycle)
    const p = PRESETS.find(x => x.key === key);
    if (!p) return;
    const g = p.group;
    const newSel = { ...selectedPresets, preset: "on", [g]: selectedPresets[g] === key ? null : key };
    setSelectedPresets(newSel);
    setParams(rebuildFromPresets(newSel));
    resetSim();
    // lastAppliedComboRef を更新（"off" 状態でのクリックで即 "on" 化した場合も含む）
    lastAppliedComboRef.current = { cycle: newSel.cycle, cond: newSel.cond, area: newSel.area };
    const active = ["area","cond","cycle"].map(gg => {
      const k = newSel[gg];
      const pp = k ? PRESETS.find(x => x.key === k) : null;
      return pp ? pp.label : null;
    }).filter(Boolean);
    showToast(active.length ? "プリセット: " + active.join(" + ") : "プリセット解除", "var(--vermilion)");
  };
  // 互換: applyPreset 名で呼ばれる箇所用
  const applyPreset = togglePreset;

  function resetSim() {
    particlesRef.current = [];
    heatmapRef.current = null;
    if (SimRenderer.resetFishShadows) SimRenderer.resetFishShadows();
    // ★ 落とし込み目安 = ビシ位置 (指示棚 + dropOffsetM)
    //   実釣標準: dropOffsetM = +5 → ビシは指示棚 5m 下 (タナ下5m)
    //   その後 shakuri × N + maki でビシを徐々に上げ、付け餌をタナへ寄せる
    //   makiOffset = -dropOffsetM (負値で cage が指示棚より下に位置)
    const pp = physicsParamsRef.current || DEFAULT_PARAMS;
    const _drop = pp.dropOffsetM != null ? pp.dropOffsetM : 5;
    const _initMaki = -_drop;
    // ★ リセット直後は潮で流れ切った状態からスタート (shakuriOffsetX を settle 済みの位置で初期化)
    //   理由: 旧実装は ROD_X_M から徐々に潮下へ流れていく途中でしゃくり開始 → 釣り座基準の X 想定とずれる
    const _tanaY = pp.tanaDepth + _drop;
    const _settledX = SimPhysics.ROD_X_M + SimPhysics.pelineDrift(pp, Math.max(0, _tanaY));
    rigStateRef.current = { shakuriOffsetY: 0, shakuriVelY: 0, shakuriOffsetX: _settledX, makiOffset: _initMaki, makiTarget: _initMaki };
    chumRef.current = 1.0;
    pendingMakiRef.current = [];
    pendingShakuriRef.current = [];
    autoStateRef.current = { state: "idle", biteTimer: 0 };
    phaseRef.current = "fishing";
    bishiAbsYRef.current = 0;
    dropVelRef.current = 0;
    setPhase("fishing");
    metricsRef.current.shakuriCount = 0;
    metricsRef.current.totalSpawned = 0;
    metricsRef.current.hitRateEMA = 0;
    // サイクル評価リセット
    cycleStatsRef.current = { goodFrames: 0, okFrames: 0, totalFrames: 0, peakSync: 0, sumSync: 0, simElapsed: 0 };
    cycleCountRef.current = 0;
    setLastCycleResult(null);
  }

  function getCageY() {
    const rs = rigStateRef.current;
    const pp = physicsParamsRef.current;
    if (phaseRef.current === "dropping") return bishiAbsYRef.current;
    return pp.tanaDepth - rs.makiOffset + rs.shakuriOffsetY;
  }

  // canvas resize
  useEffect(() => {
    const canvas = canvasRef.current;
    const resize = () => {
      const rect = canvas.parentElement.getBoundingClientRect();
      const dpr = Math.min(2, window.devicePixelRatio || 1);
      canvas.width  = Math.floor(rect.width  * dpr);
      canvas.height = Math.floor(rect.height * dpr);
      canvas.style.width = rect.width + "px";
      canvas.style.height = rect.height + "px";
      const ctx = canvas.getContext("2d");
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      canvas.__cssW = rect.width;
      canvas.__cssH = rect.height;
      // resize 時に minimap を画面内に収める（スケール考慮）
      const bowScale = rect.width <= 480 ? 0.70 : 1.0;
      const mw = (SimRenderer.BOW_VIEW_W || 178) * bowScale;
      const mh = (SimRenderer.BOW_VIEW_H || 150) * bowScale;
      const mm = minimapPosRef.current;
      if (mm) {
        mm.x = Math.max(0, Math.min(rect.width - mw, mm.x));
        mm.y = Math.max(0, Math.min(rect.height - mh, mm.y));
      } else if (rect.width <= 480) {
        // モバイル初期位置: 右上
        minimapPosRef.current = { x: rect.width - mw - 6, y: 6 };
      }
    };
    resize();
    window.addEventListener("resize", resize);
    // T40-fix: phase 変化で HUD 高さが変わる → grid 再計算 → main の rect.width が変わるが
    // canvas.style は window resize 時しか更新されず、main の background が右側に露出する問題対策
    let ro = null;
    if (typeof ResizeObserver !== "undefined" && canvas.parentElement) {
      ro = new ResizeObserver(() => resize());
      ro.observe(canvas.parentElement);
    }
    return () => {
      window.removeEventListener("resize", resize);
      if (ro) ro.disconnect();
    };
  }, []);

  // ミニビュー (船上から見た図) のドラッグ機能
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const getBowScale = () => (canvas.__cssW || 9999) <= 480 ? 0.70 : 1.0;
    const getMW = () => (SimRenderer.BOW_VIEW_W || 178) * getBowScale();
    const getMH = () => (SimRenderer.BOW_VIEW_H || 150) * getBowScale();

    const getPos = (e) => {
      const rect = canvas.getBoundingClientRect();
      const t = e.touches ? e.touches[0] : e;
      return { x: t.clientX - rect.left, y: t.clientY - rect.top };
    };
    const inMinimap = (p) => {
      const mm = minimapPosRef.current;
      if (!mm) return false;
      return p.x >= mm.x && p.x <= mm.x + getMW() && p.y >= mm.y && p.y <= mm.y + getMH();
    };

    const inToggle = (p) => {
      const mm = minimapPosRef.current;
      if (!mm) return false;
      const s = getBowScale();
      const tgX = mm.x + (SimRenderer.BOW_VIEW_W || 178) * s - (SimRenderer.BOW_VIEW_TOGGLE_X || 8) * s - (SimRenderer.BOW_VIEW_TOGGLE_W || 56) * s;
      const tgY = mm.y + (SimRenderer.BOW_VIEW_TOGGLE_Y || 32) * s;
      const tgW = (SimRenderer.BOW_VIEW_TOGGLE_W || 56) * s;
      const tgH = (SimRenderer.BOW_VIEW_TOGGLE_H || 16) * s;
      return p.x >= tgX && p.x <= tgX + tgW && p.y >= tgY && p.y <= tgY + tgH;
    };

    const onDown = (e) => {
      const p = getPos(e);
      if (inToggle(p)) {
        // 左舷↔右舷 切替
        setBowViewSide(bowViewSideRef.current === "port" ? "starboard" : "port");
        e.preventDefault();
        return;
      }
      if (inMinimap(p)) {
        const mm = minimapPosRef.current;
        minimapDragRef.current = { dx: p.x - mm.x, dy: p.y - mm.y };
        canvas.style.cursor = "grabbing";
        e.preventDefault();
      }
    };
    const onMove = (e) => {
      const p = getPos(e);
      if (minimapDragRef.current) {
        const W = canvas.__cssW || canvas.width;
        const H = canvas.__cssH || canvas.height;
        const nx = Math.max(0, Math.min(W - getMW(), p.x - minimapDragRef.current.dx));
        const ny = Math.max(0, Math.min(H - getMH(), p.y - minimapDragRef.current.dy));
        minimapPosRef.current = { x: nx, y: ny };
        e.preventDefault();
      } else {
        canvas.style.cursor = inMinimap(p) ? "grab" : "default";
      }
    };
    const onUp = () => {
      if (minimapDragRef.current) {
        minimapDragRef.current = null;
        canvas.style.cursor = "default";
      }
    };

    canvas.addEventListener("mousedown", onDown);
    canvas.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
    canvas.addEventListener("touchstart", onDown, { passive: false });
    canvas.addEventListener("touchmove", onMove, { passive: false });
    window.addEventListener("touchend", onUp);
    return () => {
      canvas.removeEventListener("mousedown", onDown);
      canvas.removeEventListener("mousemove", onMove);
      window.removeEventListener("mouseup", onUp);
      canvas.removeEventListener("touchstart", onDown);
      canvas.removeEventListener("touchmove", onMove);
      window.removeEventListener("touchend", onUp);
    };
  }, []);

  // ===== 単発しゃくり実行（withMaki=true で 0.5s 後に巻き上げをスケジュール） =====
  const _executeOneStroke = (withMaki) => {
    if (phaseRef.current !== "fishing") return;
    const rs = rigStateRef.current;
    const pp = physicsParamsRef.current;
    SimPhysics.shakuri(rs, pp);
    const cage = { x: rs.shakuriOffsetX, y: getCageY() };
    const before = particlesRef.current.length;
    const strokeIntensity = pp.shakuriStrokeCm / 50;
    SimPhysics.spawnParticles(particlesRef.current, cage, pp, strokeIntensity, MAX_PARTICLES, chumRef.current);
    metricsRef.current.totalSpawned += particlesRef.current.length - before;
    metricsRef.current.shakuriCount += 1;
    chumRef.current = Math.max(0, chumRef.current - SimPhysics.shakuriConsumption(pp));
    flashRef.current = 1.0;
    lastShakuriAtRef.current = performance.now();
    if (withMaki && pp.makiAmount > 0.01) {
      pendingMakiRef.current.push({ at: performance.now() + 500, amount: pp.makiAmount });
    }
  };

  // ===== N連発しゃくりサイクル =====
  // 2発目以降は前のしゃくりがバネ-ダンパで完全に収まってから発火する
  // (固定 500ms 間隔だと前ストロークが peak 折返し中に重なって動作が不自然)
  const _doShakuriCycle = (withMaki) => {
    if (phaseRef.current !== "fishing") return;
    const pp = physicsParamsRef.current;
    const n = Math.max(1, Math.round(pp.shakuriCountPerTrigger || 1));
    _executeOneStroke(withMaki);
    for (let i = 1; i < n; i++) {
      pendingShakuriRef.current.push({ withMaki: withMaki });
    }
  };

  // ===== 自動最適動作用: N発撃ちそれぞれが maki を伴う (per-stroke maki) =====
  // 実釣準拠: 1サイクルで N×makiAmount だけビシが上昇 → 食わせ待ち → drop で元に戻る
  // (rigLen=6, init=1, N=2, makiAmount=2.5 → peakMaki=6 → 付け餌が指示棚に到達)
  const _doOptimalShakuriBurst = () => {
    if (phaseRef.current !== "fishing") return;
    const pp = physicsParamsRef.current;
    const n = Math.max(1, Math.round(pp.shakuriCountPerTrigger || 1));
    _executeOneStroke(true);  // 1発目 + maki
    for (let i = 1; i < n; i++) {
      pendingShakuriRef.current.push({ withMaki: true });  // 後続も maki
    }
  };

  // 手動しゃくる（巻きなし）
  const triggerShakuri = () => _doShakuriCycle(false);
  const triggerRef = useRef(triggerShakuri);
  triggerRef.current = triggerShakuri;

  // 手動巻く（makiAmount だけ巻き上げ）。コマセ枯渇でも自動回収しない（仕掛け回収は手動のみ）。
  const triggerMaki = () => {
    if (phaseRef.current !== "fishing") return;
    const pp = physicsParamsRef.current;
    const physicalMaxMaki = Math.max(2, pp.tanaDepth - 1);
    const nextTarget = rigStateRef.current.makiTarget + (pp.makiAmount || 0);
    rigStateRef.current.makiTarget = Math.min(physicalMaxMaki, nextTarget);
    flashRef.current = 0.5;
  };
  const makiRef = useRef(triggerMaki);
  makiRef.current = triggerMaki;

  // 手動落とし込み（dropAmount だけビシを下げる）
  const triggerDropOnly = () => {
    if (phaseRef.current !== "fishing") return;
    const pp = physicsParamsRef.current;
    // ビシが海底に当たらない最小 makiOffset (bishi y <= depth - 2)
    const minMaki = pp.tanaDepth - pp.depth + 2;
    const nextTarget = rigStateRef.current.makiTarget - (pp.dropAmount || 0);
    rigStateRef.current.makiTarget = Math.max(minMaki, nextTarget);
    flashRef.current = 0.5;
  };
  const dropManualRef = useRef(triggerDropOnly);
  dropManualRef.current = triggerDropOnly;

  // ===== 落とし込みスキップ（ビシが指示棚 + dropOffsetM へ即着） =====
  const skipDrop = () => {
    if (phaseRef.current !== "dropping") return;
    const pp = physicsParamsRef.current;
    const drop = pp.dropOffsetM != null ? pp.dropOffsetM : 5;
    bishiAbsYRef.current = Math.max(1, pp.tanaDepth + drop);
    phaseRef.current = "fishing";
    dropVelRef.current = 0;
    rigStateRef.current.makiOffset = -drop;
    rigStateRef.current.makiTarget = -drop;
    setPhase("fishing");
  };
  const skipRef = useRef(skipDrop);
  skipRef.current = skipDrop;

  // ===== 仕掛け投入 (落とし込みフェーズ) =====
  const castRig = () => {
    phaseRef.current = "dropping";
    setPhase("dropping");
    bishiAbsYRef.current = 0;
    dropVelRef.current = physicsParamsRef.current.dropSpeed || 0.1;
    rigStateRef.current.makiOffset = 0;
    rigStateRef.current.makiTarget = 0;
    rigStateRef.current.shakuriOffsetY = 0;
    rigStateRef.current.shakuriVelY = 0;
    particlesRef.current = [];
    heatmapRef.current = null;
    pendingMakiRef.current = [];
    pendingShakuriRef.current = [];
    autoStateRef.current = { state: "idle", biteTimer: 0 };
    chumRef.current = 1.0;
    // 新サイクル開始: 集計リセット
    cycleStatsRef.current = { goodFrames: 0, okFrames: 0, totalFrames: 0, peakSync: 0, sumSync: 0, simElapsed: 0 };
  };
  const castRef = useRef(castRig);
  castRef.current = castRig;

  // ===== 仕掛け回収(コマセ補充 + 巻きリセット + 自動再投入) =====
  // 自動回収 (triggerMaki の上限到達時) と同じ挙動。回収して dropping フェーズへ。
  const retrieveRig = () => {
    // 1サイクル評価を確定 (回収前に集計結果からスコア算出)
    finalizeCycle();
    rigStateRef.current.makiTarget = 0;
    rigStateRef.current.makiOffset = 0;
    rigStateRef.current.shakuriOffsetY = 0;
    rigStateRef.current.shakuriVelY = 0;
    pendingMakiRef.current = [];
    pendingShakuriRef.current = [];
    autoStateRef.current = { state: "idle", biteTimer: 0 };
    chumRef.current = 1.0;
    particlesRef.current = [];
    heatmapRef.current = null;
    phaseRef.current = "dropping";
    setPhase("dropping");
    bishiAbsYRef.current = 0;
    dropVelRef.current = physicsParamsRef.current.dropSpeed || 1.5;
    flashRef.current = 1.4;
    // 次サイクルの集計開始
    cycleStatsRef.current = { goodFrames: 0, okFrames: 0, totalFrames: 0, peakSync: 0, sumSync: 0, simElapsed: 0 };
  };

  // ===== 1サイクル完了処理 (仕掛け回収時に呼ばれる) =====
  //   リアルタイムスコアは grade メモで毎フレーム更新中なので、ここでは
  //   サイクル番号をインクリメントして次サイクルへ進むだけ。
  function finalizeCycle() {
    const st = cycleStatsRef.current;
    if (st && st.totalFrames >= 30) {
      cycleCountRef.current += 1;
    }
  }
  const retrieveRef = useRef(retrieveRig);
  retrieveRef.current = retrieveRig;

  const paramsRef = useRef(params);
  paramsRef.current = params;
  const runningRef = useRef(running);
  runningRef.current = running;
  const speedRef = useRef(speedMul);
  speedRef.current = speedMul;

  // ===== Animation loop =====
  useEffect(() => {
    let rafId;
    let intervalId;
    const loop = (t) => {
      if (!lastTimeRef.current) lastTimeRef.current = t;
      let dt = (t - lastTimeRef.current) / 1000;
      lastTimeRef.current = t;
      if (dt > 0.1) dt = 0.1;
      // 倍速適用 (実時間 dt × 倍率)。物理積分は本来 dt が大きいと不安定だが、
      // 0.1 × 3 = 0.3 程度なら粒子・rigStep は安定範囲内。
      dt *= (speedRef.current || 1);
      const params = paramsRef.current;
      const pp = physicsParamsRef.current;
      const running = runningRef.current;
      if (!running) dt = 0;

      // === 落とし込みフェーズ ===
      // 関東コマセマダイ実釣: 「指示ダナ下 5m」は **付けエサ(hook)** が下 5m に来る位置
      // → ビシ(cage) は付けエサより (cushion+harris) m 上、つまり tana - (cushion+harris) + 5
      // その後 3回しゃくり×1.7m巻きで付けエサが指示ダナに到達 → 待ち時間 → 回収
      if (running && phaseRef.current === "dropping") {
        // ★ ビシ落としこみ目安: 指示棚 + dropOffsetM
        const drop = pp.dropOffsetM != null ? pp.dropOffsetM : 5;
        const dropTarget = Math.max(1, pp.tanaDepth + drop);
        if (bishiAbsYRef.current < dropTarget) {
          // 沈降中
          const vel = pp.dropSpeed || 1.5;
          dropVelRef.current = vel;
          bishiAbsYRef.current += vel * dt;
          if (bishiAbsYRef.current >= dropTarget) {
            bishiAbsYRef.current = dropTarget;
            // ビシは tana + drop に固定 → makiOffset = -drop
            rigStateRef.current.makiOffset = -drop;
            rigStateRef.current.makiTarget = -drop;
            // dropVel はここでゼロにしない (settling で指数減衰させて潮なじみを再現)
          }
        } else {
          // 着定後の潮なじみ (物理ベース・二次抗力モデル):
          //   dv/dt = -k * v²  (高Re域の流体抗力 = 0.5*ρ*Cd*A*v²)
          //   解: v(t) = v0 / (1 + k*v0*t) — 初期は急減速、終盤はゆっくり=実釣のなじみ感
          //
          //   k の係数は ハリス全長×抗力係数 / (ガン玉+hook 重量) に比例:
          //     重いガン玉 → 速く落ちる (k_drag 大)
          //     長い/太いハリス → ゆっくり落ちる (k_drag 小)
          //
          //   典型例 (ガン玉5号, ハリス8m #3, hook 0.5g): k_drag ~ 3-5 → なじみ ~3-5秒
          const ganW = (pp.ganDamaSize || 0) * 0.9;
          const hookW = pp.hookWeight || 0.5;
          const harrisDragCoef = (0.04 + (pp.harrisNo || 3) * 0.045) * 10;
          const harrisLen = pp.harrisLength || 8;
          const k_drag = Math.max(1.5, Math.min(15, 40 * (ganW + hookW) / (harrisLen * harrisDragCoef)));
          const v = dropVelRef.current || 0;
          dropVelRef.current = v / (1 + k_drag * v * dt);
          if (dropVelRef.current < 0.03) {
            dropVelRef.current = 0;
            phaseRef.current = "fishing";
            setPhase("fishing");
          }
        }
      } else {
        dropVelRef.current = 0;
      }

      // === うねり ===
      if (running) {
        swellPhaseRef.current += dt;
        const period = Math.max(1, pp.swellPeriod || 6);
        const amp = (pp.swellHeight || 0) * 0.5; // peak-to-trough/2
        swellOffsetYRef.current = amp * Math.sin(swellPhaseRef.current * 2 * Math.PI / period);
      }

      // 自動最適動作 (fishing 中のみ): 状態マシンで shakuri→biting→dropping→shakuri を回す
      //   shakuri:  N発撃ち中 (最終発のみ maki) → 終わって maki が完了したら biting
      //   biting:   高位置で食わせ待ち (shakuriInterval 秒)
      //   dropping: makiTarget=0 でタナへ戻す → 着いたら次の shakuri 開始
      if (running && params.autoShakuri && phaseRef.current === "fishing") {
        const auto = autoStateRef.current;
        const rs = rigStateRef.current;

        if (auto.state === "idle") {
          // 初回: shakuri 開始
          _doOptimalShakuriBurst();
          auto.state = "shakuri";
          auto.biteTimer = 0;
        } else if (auto.state === "shakuri") {
          // 全 N 発と maki が完了したら biting へ
          const allStrokesDone = pendingShakuriRef.current.length === 0;
          const allMakiDone = pendingMakiRef.current.length === 0;
          const rigSettled = Math.abs(rs.shakuriVelY) < 0.12 && Math.abs(rs.shakuriOffsetY) < 0.05;
          const makiSettled = Math.abs((rs.makiTarget || 0) - (rs.makiOffset || 0)) < 0.08;
          if (allStrokesDone && allMakiDone && rigSettled && makiSettled) {
            auto.state = "biting";
            auto.biteTimer = 0;
          }
        } else if (auto.state === "biting") {
          auto.biteTimer += dt;
          if (auto.biteTimer >= (pp.shakuriInterval || 60)) {
            // 食わせ時間終了 → ビシを落としこみ目安位置 (tana + dropOffsetM) に戻す
            // 「巻きすぎ累積で cage が tana より上に固定」のドリフトを防ぐため、
            // dropBack ではなく ALWAYS baseMaki にスナップ。
            const drop = pp.dropOffsetM != null ? pp.dropOffsetM : 5;
            rs.makiTarget = -drop;
            auto.state = "dropping";
          }
        } else if (auto.state === "dropping") {
          // ビシが目標位置に戻ったら次サイクル開始
          const makiSettled = Math.abs((rs.makiTarget || 0) - (rs.makiOffset || 0)) < 0.08;
          if (makiSettled) {
            _doOptimalShakuriBurst();
            auto.state = "shakuri";
            auto.biteTimer = 0;
          }
        }
      } else {
        // 自動 OFF または fishing 外 → idle にリセット
        autoStateRef.current.state = "idle";
        autoStateRef.current.biteTimer = 0;
      }

      // Pending shakuri (N連発の2発目以降)
      // 前のしゃくりが完全に収まる(バネ-ダンパが落ち着く)まで待ってから次を発火。
      //   settled = |shakuriOffsetY| < 5cm かつ |shakuriVelY| < 0.12 m/s
      //   さらに前ストロークから最低 600ms 経過 (視覚的な間を確保)
      //   最大 3 秒で強制発火 (安全弁: 何らかの理由で収束しなくても進行)
      const now = performance.now();
      if (pendingShakuriRef.current.length > 0 && phaseRef.current === "fishing") {
        const rs = rigStateRef.current;
        const settled = Math.abs(rs.shakuriOffsetY) < 0.05 && Math.abs(rs.shakuriVelY) < 0.12;
        const elapsed = now - lastShakuriAtRef.current;
        if ((settled && elapsed >= 600) || elapsed >= 3000) {
          const next = pendingShakuriRef.current.shift();
          _executeOneStroke(!!next.withMaki);
        }
      }

      // Pending maki (0.5s 後に巻く)
      // 自動回収: コマセ枯渇時のみ。巻き上げが過剰になっても勝手にリセットしない
      //（ユーザーが「仕掛け回収」を手動で押すまで保持）。
      // makiTarget の上限はビシが水面 1m 以下に出ない範囲だけ守る。
      const _rigLen2 = (pp.cushionLength || 1) + (pp.harrisLength || 8);
      const physicalMaxMaki = Math.max(2, pp.tanaDepth - 1); // bishi y >= 1m (水面より下)
      for (let i = pendingMakiRef.current.length - 1; i >= 0; i--) {
        if (now >= pendingMakiRef.current[i].at) {
          const nextTarget = rigStateRef.current.makiTarget + pendingMakiRef.current[i].amount;
          // 巻きすぎは物理的上限でクランプするだけ（リセットしない）
          // コマセ空でも自動リセットしない: 回収はユーザーが「仕掛け回収」で明示
          rigStateRef.current.makiTarget = Math.min(physicalMaxMaki, nextTarget);
          pendingMakiRef.current.splice(i, 1);
        }
      }
      // 巻き過ぎ / 残量0 で自動回収サジェスト (実回収はユーザー操作)
      // ただし、makiTarget が水面に達する前にあえて止めない

      // コマセの連続漏れ
      if (running) {
        chumRef.current = Math.max(0, chumRef.current - SimPhysics.leakRate(pp) * dt);
      }

      // ビシ動力学
      SimPhysics.rigStep(rigStateRef.current, pp, dt);

      // 連続放出 (残量に比例・fishing 中のみ・下窓主導)
      const lowerOpen = pp.cageLowerOpening != null ? pp.cageLowerOpening : (pp.cageOpening != null ? pp.cageOpening : 0);
      if (running && phaseRef.current === "fishing" && lowerOpen > 0.05 && chumRef.current > 0.01) {
        accumRef.current += dt;
        const rate = (0.4 + lowerOpen * 4) * chumRef.current;
        const need = accumRef.current * rate;
        if (need >= 1) {
          const n = Math.floor(need);
          accumRef.current -= n / rate;
          const rs = rigStateRef.current;
          const cage = { x: rs.shakuriOffsetX, y: getCageY() };
          // 漏れ粒子も komaseSize/smoke 連動に (physics.jsx simulateHeadless と一致)
          const _sz = SimPhysics.komaseSizeProps(pp);
          const _sm = SimPhysics.smokeLevelProps(pp);
          for (let i = 0; i < n; i++) {
            if (particlesRef.current.length >= MAX_PARTICLES) break;
            particlesRef.current.push({
              x: cage.x + (Math.random()-0.5)*0.2,
              y: cage.y + (Math.random()-0.5)*0.2 + 0.1,
              vx: 0, vy: 0,
              life: 0.85 * _sz.lifeMul * _sm.lifeMul,
              size: _sz.sizeBase * (0.7 + Math.random()*0.4),
              terminal: _sz.terminalBase * _sm.terminalMul * (0.85 + Math.random()*0.3),
              alpha: _sm.alphaMul,
            });
            metricsRef.current.totalSpawned += 1;
          }
        }
      }

      // 粒子更新
      SimPhysics.stepParticles(particlesRef.current, pp, dt);

      // 仕掛け形状 (落とし込み中は dropVel を渡してハリスを上方に流す)
      const cageYForRig = phaseRef.current === "dropping"
        ? bishiAbsYRef.current
        : pp.tanaDepth - rigStateRef.current.makiOffset;
      const rig = SimPhysics.rigShape(
        cageYForRig,
        rigStateRef.current.shakuriOffsetY,
        rigStateRef.current.shakuriOffsetX,
        pp,
        pp.hookWeight,
        dropVelRef.current
      );

      // メトリクス (圏内 1.8m = 魚が匂いで感知する範囲)
      // 同調判定半径 3.0m: physics.jsx simulateHeadless と一致 (実釣感覚)
      const near = SimPhysics.nearHook(particlesRef.current, rig.hook, 3.0);
      const total = particlesRef.current.length;
      const ratio = total > 0 ? near / total * 100 : 0;
      const a = 0.04;
      metricsRef.current.hitRateEMA = metricsRef.current.hitRateEMA * (1 - a) + ratio * a;
      metricsRef.current.hookDepth = rig.hook.y;
      metricsRef.current.harrisAngleDeg = rig.theta * 180 / Math.PI;
      // 仕掛け鉛直距離 (ビシ → 付けエサの実 y 差) / 仕掛け全長 (cushion + harris)
      const rigLenTotal = (pp.cushionLength || 1) + (pp.harrisLength || 8);
      const vertLen = rig.hook.y - rig.cage.y;
      metricsRef.current.rigVertical = vertLen;
      metricsRef.current.rigTotal = rigLenTotal;
      metricsRef.current.rigVerticalRatio = rigLenTotal > 0 ? vertLen / rigLenTotal : 1;
      // PE道糸 (釣り人(竿先 world x=ROD_X_M, y≈0) → ビシ) の鉛直距離 / 斜距離
      const rodX = SimPhysics.ROD_X_M;
      const peVert = rig.cage.y; // 竿先 y ≈ 0
      const peHoriz = rig.cage.x - rodX;
      const peTotal = Math.sqrt(peVert * peVert + peHoriz * peHoriz);
      metricsRef.current.peVertical = peVert;
      metricsRef.current.peHorizontal = peHoriz;
      metricsRef.current.peTotal = peTotal;

      // タナ取り完了検出 (ビシが指示棚に着いた瞬間)
      //   コマセマダイの実釣: 「タナ取り」= ビシを船長指示の指示棚に止める作業
      //   付け餌はビシより下のコマセ帯に自然に流れるので、付け餌の y は判定対象外
      if (running && phaseRef.current === "fishing") {
        const cageOnTana = Math.abs(rig.cage.y - pp.tanaDepth) < 0.5;
        if (cageOnTana && !tanaArrivalLastRef.current) {
          tanaArrivalLastRef.current = true;
          flashRef.current = 1.6;
          showToast("✓ タナ取り完了 — 待ち時間", "var(--moss)");
        }
        if (!cageOnTana && Math.abs(rig.cage.y - pp.tanaDepth) > 1.0) {
          tanaArrivalLastRef.current = false;
        }

        // 1サイクル集計: フレーム毎の良否を累積 (リアルタイムスコア計算)
        //   good: ビシ指示棚 ±1.5m & 付け餌ビシ下0.5m以上 & 付け餌深場ゾーン & 同調 >=2%
        //   ok:   ビシ ±2.5m & 付け餌ビシ下 & 同調 >=0.5%
        const cycSt = cycleStatsRef.current;
        const syncRate = metricsRef.current.hitRateEMA;  // %
        const cageDiff = Math.abs(rig.cage.y - pp.tanaDepth);
        const hookBelowCage = rig.hook.y - rig.cage.y;
        const hookInDeepZone = rig.hook.y >= pp.tanaDepth - 1 && rig.hook.y <= pp.depth - 1;
        const goodFrame = (cageDiff <= 1.5 && hookBelowCage > 0.5 && hookInDeepZone && syncRate >= 2) ? 1 : 0;
        const okFrame = (cageDiff <= 2.5 && hookBelowCage > 0 && syncRate >= 0.5) ? 1 : 0;
        cycSt.totalFrames += 1;
        cycSt.goodFrames += goodFrame;
        cycSt.okFrames += okFrame;
        cycSt.sumSync += syncRate;
        if (syncRate > cycSt.peakSync) cycSt.peakSync = syncRate;
        // シミュ経過時間累積 (倍速時は実時間より速く進む)
        cycSt.simElapsed += dt;
        // リアルタイムスコア: 累積フレーム数に応じて秒ごとに更新
        //   フレーム数が少ない初期はスコアが伸びていく感覚を出す
        if (cycSt.totalFrames > 0) {
          const sustainRate = cycSt.goodFrames / cycSt.totalFrames;
          const okRate = cycSt.okFrames / cycSt.totalFrames;
          const meanSync = cycSt.sumSync / cycSt.totalFrames;
          // 配点: sustain 50 + ok 15 + meanSync 15 + peak 20 = max 100
          //   peakBonus を min(20, peak*0.4) に変更 (旧: min(10, peak*0.8) → 12%以上で頭打ち)
          //   peakSync 50% で 20pt 満点 → 「短時間でも sync 達成」を強く評価
          const baseScore = sustainRate * 50 + okRate * 15;
          const syncBonus = Math.min(15, meanSync * 1.5);
          const peakBonus = Math.min(20, cycSt.peakSync * 0.4);
          metricsRef.current.cycleScore = Math.max(0, Math.min(100, baseScore + syncBonus + peakBonus));
          metricsRef.current.cycleSustainRate = sustainRate * 100;
          metricsRef.current.cycleOkRate = okRate * 100;
          metricsRef.current.cycleMeanSync = meanSync;
          metricsRef.current.cyclePeakSync = cycSt.peakSync;
          metricsRef.current.cycleDurationSec = cycSt.simElapsed;
        }
      }
      const histo = SimPhysics.depthHistogram(particlesRef.current, pp.depth, 24, rig.hook.y);
      metricsRef.current.histogram = histo.bins;
      metricsRef.current.hookBin = histo.hookBin;

      // タナ下流出率 + コマセ雲の中心深度
      let belowTana = 0;
      let sumY = 0, countY = 0;
      for (let i = 0; i < particlesRef.current.length; i++) {
        const p = particlesRef.current[i];
        if (p.y > pp.tanaDepth + 1) belowTana++;
        if (p.life > 0.3) { sumY += p.y; countY += 1; }
      }
      const belowRatio = total > 0 ? (belowTana / total * 100) : 0;
      metricsRef.current.belowTanaRatio = metricsRef.current.belowTanaRatio == null
        ? belowRatio
        : metricsRef.current.belowTanaRatio * (1 - a) + belowRatio * a;
      metricsRef.current.komaseDepth = countY > 0 ? sumY / countY : null;

      // ビシ深度 (fishing 中) — 指示棚より深ければ警告対象
      // shakuriOffsetY の瞬間的なオシレーションは無視したいので、makiOffset ベースで判定
      const cageDepthBase = pp.tanaDepth - (rigStateRef.current.makiOffset || 0);
      const cageOverrun = cageDepthBase - pp.tanaDepth;  // 正の値 = ビシが指示棚より深い
      metricsRef.current.cageOverrun = metricsRef.current.cageOverrun == null
        ? cageOverrun
        : metricsRef.current.cageOverrun * (1 - a) + cageOverrun * a;

      // 描画
      const canvas = canvasRef.current;
      if (canvas && canvas.__cssW) {
        const ctx = canvas.getContext("2d");
        const W = canvas.__cssW;
        const H = canvas.__cssH;
        const map = SimRenderer.makeMap({width: W, height: H}, pp);
        if (!heatmapRef.current) heatmapRef.current = SimRenderer.makeHeatmap(60, 40);
        SimRenderer.heatmapStep(heatmapRef.current, map, particlesRef.current, dt);

        ctx.clearRect(0, 0, W, H);
        const swellPx = swellOffsetYRef.current * map.sy;
        SimRenderer.drawBackground(ctx, map, pp, swellPhaseRef.current);
        SimRenderer.drawHeatmap(ctx, heatmapRef.current, map);
        SimRenderer.drawCurrent(ctx, map, pp);

        const rodTipY = map.y(0) - 36 - swellPx;
        SimRenderer.drawBoat(ctx, map, rodTipY, swellPx);
        SimRenderer.drawFishShadows(ctx, map, pp, particlesRef.current);
        SimRenderer.drawRig(ctx, map, rig, pp, rodTipY, chumRef.current, swellPx);
        SimRenderer.drawParticles(ctx, map, particlesRef.current);
        const ppLabels = Object.assign({}, pp, { _komaseDepth: metricsRef.current.komaseDepth });
        SimRenderer.drawLabels(ctx, map, ppLabels, rig, phaseRef.current, swellOffsetYRef.current);
        // ミニビュー位置 (未設定なら左側 HUD ボックス下)
        const bowScaleTick = (canvasRef.current.__cssW || 9999) <= 480 ? 0.70 : 1.0;
        if (!minimapPosRef.current) {
          const cssW = canvasRef.current.__cssW || 500;
          minimapPosRef.current = cssW <= 480
            ? { x: cssW - (SimRenderer.BOW_VIEW_W || 178) * 0.70 - 6, y: 6 }
            : { x: 14, y: 200 };
        }
        SimRenderer.drawBowView(ctx, minimapPosRef.current.x, minimapPosRef.current.y, pp, rig, bowViewSideRef.current, bowScaleTick);

        if (flashRef.current > 0) {
          flashRef.current -= dt * 3.5;
          const fa = Math.max(0, flashRef.current);
          ctx.fillStyle = `rgba(255, 255, 255, ${fa * 0.08})`;
          ctx.fillRect(0, 0, W, H);
        }
      }

      // panels tick
      if ((Math.floor(t / 100)) !== (Math.floor((t - dt*1000) / 100))) {
        setTick(k => (k + 1) % 1000000);
      }

      rafId = requestAnimationFrame(loop);
    };
    rafId = requestAnimationFrame(loop);
    intervalId = setInterval(() => {
      if (document.visibilityState === "visible") return;
      loop(performance.now());
    }, 250);
    return () => { cancelAnimationFrame(rafId); clearInterval(intervalId); };
  }, []);

  // 初期同期描画
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas || !canvas.__cssW) return;
    const ctx = canvas.getContext("2d");
    const map = SimRenderer.makeMap({width: canvas.__cssW, height: canvas.__cssH}, physicsParams);
    if (!heatmapRef.current) heatmapRef.current = SimRenderer.makeHeatmap(60, 40);
    const rig = SimPhysics.rigShape(physicsParams.tanaDepth, 0, 0, physicsParams, physicsParams.hookWeight, 0);
    ctx.clearRect(0, 0, canvas.__cssW, canvas.__cssH);
    SimRenderer.drawBackground(ctx, map, physicsParams, 0);
    SimRenderer.drawCurrent(ctx, map, physicsParams);
    SimRenderer.drawBoat(ctx, map, map.y(0) - 36, 0);
    SimRenderer.drawFishShadows(ctx, map, physicsParams, particlesRef.current);
    SimRenderer.drawRig(ctx, map, rig, physicsParams, map.y(0) - 36, chumRef.current, 0);
    SimRenderer.drawLabels(ctx, map, physicsParams, rig, phaseRef.current, 0);
    const bowScaleStatic = (canvasRef.current.__cssW || 9999) <= 480 ? 0.70 : 1.0;
    if (!minimapPosRef.current) {
      const cssW = canvasRef.current.__cssW || 500;
      minimapPosRef.current = cssW <= 480
        ? { x: cssW - (SimRenderer.BOW_VIEW_W || 178) * 0.70 - 6, y: 6 }
        : { x: 14, y: 200 };
    }
    SimRenderer.drawBowView(ctx, minimapPosRef.current.x, minimapPosRef.current.y, physicsParams, rig, bowViewSideRef.current, bowScaleStatic);
  }, [physicsParams, bowViewSide]);

  // ===== 自動最適化 =====
  const runOptimize = async () => {
    setOptimizing(true);
    setRecommendation(null);
    await new Promise(r => setTimeout(r, 50));
    // ★ baseline は optimizer 内部評価 (120s×3run) と必ず同条件にする
    const baseline = SimPhysics.evalParams(physicsParams, 120, 3);
    const lockedValues = {};
    for (const k of LOCKABLE_PARAMS) {
      if (locks[k]) lockedValues[k] = physicsParams[k];
    }
    const result = await SimPhysics.optimizeAsync(physicsParams, 64, lockedValues);
    setRecommendation({ ...result, baseline, locked: { ...lockedValues } });
    setOptimizing(false);
  };
  const applyRecommendation = () => {
    if (!recommendation) return;
    setParams(prev => ({ ...prev, ...recommendation.best }));
    // プリセット選択は維持する（「東京湾・剣崎ベース + 推奨適用」を視覚的に追えるように）
    resetSim();
    setDrawerOpen(false);
    showToast("✓ 推奨設定を適用しました", "var(--moss)");
  };

  // ===== Grade =====
  // コマセマダイの実釣ロジック:
  //   指示棚 = ビシを止める作戦水深 (船長指示・絶対遵守)
  //   付け餌 = ビシより下〜潮下のコマセ帯に自然に漂う (指示棚に合わせる必要はない)
  //   マダイ = 底寄りにいる → コマセに誘われて浮上 → ハリス先の付け餌を食う
  const grade = useMemo(() => {
    const rate = metricsRef.current.hitRateEMA;
    const hookY = metricsRef.current.hookDepth;
    const cageY = (params.tanaDepth - (rigStateRef.current.makiOffset || 0));
    const cageDiff = cageY - params.tanaDepth;       // ビシのタナズレ (船長指示基準)
    const absCageDiff = Math.abs(cageDiff);
    const hookBelowCage = hookY - cageY;             // 付け餌がビシより下にある量 (m)
    const hookInZone = hookY >= params.tanaDepth - 1 && hookY <= params.depth - 1;
    let g, note;
    // 1. ビシが指示棚から大きく外れている = 船長指示違反 (最優先で×)
    if (absCageDiff > 3) {
      g = "×";
      note = `ビシが指示棚から ${absCageDiff.toFixed(1)}m ズレ — 船全体のコマセ棚を崩す。巻きで調整。`;
    } else if (absCageDiff > 1.5) {
      g = "△";
      note = `ビシが指示棚から ${absCageDiff.toFixed(1)}m ズレ — 巻きで指示棚に戻す。`;
    } else if (hookBelowCage < 0.5) {
      // 2. 付け餌がビシより上にある = 不自然 (落とし遅れ・ハリス絡み)
      g = "×";
      note = "付け餌がビシより上にある。落とし込みの潮なじみが不十分。";
    } else if (!hookInZone) {
      // 3. 付け餌が深場ゾーンから外れている (底に着く or 浅すぎ)
      g = "△";
      note = "付け餌の位置が深場ゾーンから外れている。ガン玉/ハリス長/底潮を見直し。";
    } else if (rate < 0.5) {
      g = "×";
      note = "コマセ雲が付け餌に届いていない。しゃくり振り幅/上窓・下窓を増やす。";
    } else if (rate < 2) {
      g = "△";
      note = "コマセ雲が薄い。しゃくり間隔/巻き量を見直し、コマセ帯を維持。";
    } else if (rate < 4) {
      g = "○";
      note = "ビシ指示棚 + 付け餌がコマセ帯に同調。マダイが浮く配置。";
    } else {
      g = "◎";
      note = "ビシ指示棚 + 付け餌が濃いコマセ帯に同調。底からマダイを誘導する理想配置。";
    }

    // 「待ち推奨」判定: 付け餌がコマセ帯に入っていて、ビシも指示棚にいる
    // → 追加しゃくりは不要、待ってマダイが浮いて食うのを待つ
    let waitHint = null;
    if (absCageDiff <= 1.5 && hookBelowCage >= 0.5 && rate >= 2.5 && phaseRef.current === "fishing") {
      waitHint = {
        active: true,
        label: rate >= 4 ? "✓ ベスト重なり中 — 待つ！" : "✓ 重なり良好 — 追いコマセ控えめに",
        color: rate >= 4 ? "var(--moss)" : "var(--brass)",
      };
    }

    // ビシ位置警告: ビシ自体が指示棚より深く沈んでいたら警告 (タナ取り失敗)
    //   指示棚下にビシが居ると、コマセは指示棚より深い位置から放出される → 魚のタナ外しまで散らす
    //   原因: 落とし込み過ぎ・巻き上げ不足・ガン玉重すぎでハリスが沈む
    const belowRatio = metricsRef.current.belowTanaRatio || 0;
    const cageOver = metricsRef.current.cageOverrun || 0;
    let belowWarning = null;
    if (cageOver >= 8.0) {
      belowWarning = { level: "high", text: `⚠ ビシが指示棚より ${cageOver.toFixed(1)}m 下 — タナ取り直しを!`, color: "var(--vermilion)" };
    } else if (cageOver >= 6.0) {
      belowWarning = { level: "mid", text: `⚠ ビシが指示棚より ${cageOver.toFixed(1)}m 下 — 巻き上げて`, color: "var(--brass)" };
    }

    // リアルタイムサイクルスコア (毎フレーム更新中の値を grade メモへ反映)
    const cycScore = metricsRef.current.cycleScore || 0;
    let cycGrade, cycNote;
    if (cycScore >= 70)      { cycGrade = "◎"; cycNote = "ビシ指示棚 + コマセ帯と完全同調を持続中。理想的。"; }
    else if (cycScore >= 50) { cycGrade = "○"; cycNote = "コマセ帯と同調中。微調整で更に上を狙える。"; }
    else if (cycScore >= 25) { cycGrade = "△"; cycNote = "同調率が薄い。しゃくり/タナ取りを見直し。"; }
    else                     { cycGrade = "×"; cycNote = "コマセ帯と付け餌がズレている。再調整を。"; }

    return {
      grade: g, gradeNote: note,
      hitRate: rate,
      hookDepth: metricsRef.current.hookDepth,
      harrisAngleDeg: metricsRef.current.harrisAngleDeg,
      tanaDiff: cageDiff,
      hookBelowCage,
      histogram: metricsRef.current.histogram,
      hookBin: metricsRef.current.hookBin,
      shakuriCount: metricsRef.current.shakuriCount,
      totalSpawned: metricsRef.current.totalSpawned,
      waitHint,
      belowRatio,
      belowWarning,
      komaseDepth: metricsRef.current.komaseDepth,
      rigVertical: metricsRef.current.rigVertical,
      rigTotal: metricsRef.current.rigTotal,
      rigVerticalRatio: metricsRef.current.rigVerticalRatio,
      peVertical: metricsRef.current.peVertical,
      peHorizontal: metricsRef.current.peHorizontal,
      peTotal: metricsRef.current.peTotal,
      // リアルタイムサイクルスコア
      cycleScore: cycScore,
      cycleGrade: cycGrade,
      cycleNote: cycNote,
      cycleSustainRate: metricsRef.current.cycleSustainRate || 0,
      cycleOkRate: metricsRef.current.cycleOkRate || 0,
      cycleMeanSync: metricsRef.current.cycleMeanSync || 0,
      cyclePeakSync: metricsRef.current.cyclePeakSync || 0,
      cycleDurationSec: metricsRef.current.cycleDurationSec || 0,
      cycleNo: cycleCountRef.current,
    };
  }, [tick, params.tanaDepth, phase]);

  const chum = chumRef.current;
  const chumColor = chum > 0.3 ? "var(--paper)" : chum > 0.1 ? "var(--brass)" : "var(--vermilion)";

  return (
    <div className="app">
      <header className="head app__head">
        <div>
          <h1 className="head__title">マダイコマセシミュレーター <span className="head__ver">ベータ版</span></h1>
          <div className="head__actions">
            <a href="/" className="head__link" aria-label="船釣り予想 トップへ戻る">← 船釣り予想 トップへ</a>
            {/* X シェアボタン (結果連動): ラベルに現在のスコアを含めて投稿前に見える */}
            {(() => {
              const score = Math.round(grade.cycleScore || 0);
              const cg = grade.cycleGrade || "×";
              const tana = Math.round(params.tanaDepth || 0);
              const harris = (params.harrisLength || 0).toFixed(1).replace(/\.0$/, "");
              const cnt = params.shakuriCountPerTrigger || 1;
              const interval = params.shakuriInterval || 60;
              let intro;
              if (score === 0)         intro = "🎣 マダイコマセシミュ、触ってみた";
              else if (cg === "◎")     intro = "🎣 マダイコマセ、理想配置きた！";
              else if (cg === "○")     intro = "🎣 マダイコマセ、手応えあり";
              else if (cg === "△")     intro = "🎣 マダイコマセ、まだ調整中";
              else                     intro = "🎣 マダイコマセ、特訓中";
              const body = score === 0
                ? "ハリス長・ガン玉・しゃくりの最適解を物理シミュレーション"
                : `スコア ${score}点 (${cg}判定)\nタナ${tana}m・ハリス${harris}m・しゃくり${cnt}発/${interval}s`;
              const tweetText = intro + "\n" + body;
              const pageUrl = score === 0
                ? "https://funatsuri-yoso.com/komase-sim/"
                : "https://funatsuri-yoso.com/komase-sim/play/";
              const tweetUrl =
                "https://twitter.com/intent/tweet?text=" + encodeURIComponent(tweetText) +
                "&url=" + encodeURIComponent(pageUrl) +
                "&hashtags=" + encodeURIComponent("マダイ,コマセ釣り,船釣り");
              const btnLabel = score === 0
                ? "𝕏 でシェア"
                : `𝕏 シェア (${score}点${cg})`;
              const ariaLbl = score === 0
                ? "シミュレーターをXでシェア"
                : `現在の結果 ${score}点 ${cg}判定 をXに投稿`;
              return (
                <a
                  className="head__btn head__btn--x"
                  href={tweetUrl}
                  target="_blank" rel="noopener nofollow"
                  aria-label={ariaLbl}
                  title={ariaLbl}
                >{btnLabel}</a>
              );
            })()}
            <a
              className="head__btn head__btn--follow"
              href="https://twitter.com/intent/follow?screen_name=funatsuri_yoso"
              target="_blank" rel="noopener nofollow"
              aria-label="@funatsuri_yoso をフォロー"
            >フォロー</a>
            <button className="head__btn mob-settings-btn" onClick={() => setDrawerOpen(true)} aria-label="設定パネルを開く">⚙ 設定</button>
          </div>
        </div>
        <div className="head__meta">
          <div>SIM <b>● LIVE</b>　{Math.round(particlesRef.current.length)}粒</div>
          <div>累計しゃくり {metricsRef.current.shakuriCount}回</div>
        </div>
      </header>

      <div className="mob-strip">
        {/* T-9: 合否グレード常時表示 */}
        {(() => {
          const cg = grade.cycleGrade || "×";
          const cgColor = cg === "◎" ? "#5d6b3a" : cg === "○" ? "#9d7a3a" : cg === "△" ? "#c84427" : "#8a3a1c";
          return (
            <div className="mob-strip__item">
              <span className="mob-strip__lbl">合否</span>
              <span className="mob-strip__val" style={{color: cgColor, fontSize: "18px"}}>{cg}</span>
            </div>
          );
        })()}
        <div className="mob-strip__item">
          <span className="mob-strip__lbl">ヒット率</span>
          <span className="mob-strip__val" style={{color: grade.hitRate >= 4 ? "var(--pos)" : grade.hitRate >= 2 ? "var(--gold)" : "var(--neg)"}}>{grade.hitRate.toFixed(0)}%</span>
        </div>
        <div className="mob-strip__item">
          <span className="mob-strip__lbl">コマセ残量</span>
          <span className="mob-strip__val" style={{color: chumColor}}>{Math.round(chum * 100)}%</span>
        </div>
        <div className="mob-strip__item">
          <span className="mob-strip__lbl">タナ下流出</span>
          <span className="mob-strip__val" style={{color: (grade.belowRatio || 0) > 30 ? "var(--neg)" : "var(--gold)"}}>{(grade.belowRatio || 0).toFixed(0)}%</span>
        </div>
        <span className="mob-strip__phase">{phase}</span>
      </div>

      <LeftPanel params={params} set={set} locks={locks} toggleLock={toggleLock} />

      <main className="stage app__main">
        <canvas ref={canvasRef} className="stage__canvas" />
        {toast && (
          <div className="toast" style={{
            position: "absolute",
            top: 14,
            left: "50%",
            transform: "translateX(-50%)",
            padding: "10px 22px",
            background: "rgba(13, 43, 74, 0.94)",
            border: "1px solid " + toast.color,
            borderRadius: "6px",
            color: toast.color,
            fontFamily: "var(--sans)",
            fontWeight: 700,
            fontSize: 14,
            letterSpacing: ".12em",
            pointerEvents: "none",
            boxShadow: "0 4px 20px rgba(13, 43, 74, 0.3)",
            zIndex: 10,
            animation: "toastFade 2.2s ease-in-out forwards",
          }}>{toast.text}</div>
        )}
        <div className="stage__hud">
          <div>指示ダナ</div>
          {phase === "dropping" && <div style={{color:"var(--vermilion)", fontFamily:"var(--mono)", fontSize:10, marginTop:2}}>フォール中</div>}
          <span className="hud-big">{params.tanaDepth}<small style={{fontSize:14, opacity:.6, marginLeft:3}}>m</small></span>
          {phase === "dropping" && (() => {
            const motosLen = (params.motosEnabled === false ? 0 : (params.motosLength || 0));
            const rigLen = (params.cushionLength || 1) + motosLen + (params.harrisLength || 8);
            const drop = params.dropOffsetM != null ? params.dropOffsetM : 5;
            const bishiTarget = Math.max(1, params.tanaDepth + drop);
            const hookY = bishiAbsYRef.current + rigLen;
            return (
              <div className="hud__drop-detail" style={{marginTop:6, fontFamily:"var(--mono)", fontSize:11, color:"var(--paper)"}}>
                ビシ {bishiAbsYRef.current.toFixed(1)}/{bishiTarget.toFixed(0)}m<br/>
                付けエサ {hookY.toFixed(1)}m → {(params.tanaDepth + 5).toFixed(0)}m (下5m)
              </div>
            );
          })()}
          <div className="hud__chum">
            <div style={{marginTop:14, fontSize:11, letterSpacing:".15em"}}>コマセ残量</div>
            <div style={{
              width: 120, height: 8, marginTop: 5,
              background: "rgba(255,255,255,0.18)",
              border: "1px solid rgba(255,255,255,0.35)",
              borderRadius: "4px",
              overflow: "hidden",
            }}>
              <div style={{
                height:"100%",
                width: Math.max(0, Math.min(100, chum * 100)) + "%",
                background: chumColor,
                transition: "background 0.2s",
              }}></div>
            </div>
            <div style={{
              fontFamily:"var(--mono)", fontSize:11, marginTop:3,
              color: chumColor
            }}>{Math.round(chum * 100)}%</div>
          </div>
        </div>
        <div className="stage__legend">
          <div style={{fontFamily:"var(--sans)", fontWeight:700, fontSize:12, color:"var(--text)", marginBottom:6, letterSpacing:".12em"}}>凡例</div>
          <div className="legend-row"><span className="legend-dot" style={{background:"#fdba74"}}></span><b>コマセ粒子</b></div>
          <div className="legend-row"><span className="legend-dot" style={{background:"#e85d04"}}></span>付けエサ</div>
          <div className="legend-row"><span className="legend-dot" style={{background:"#1e293b", border:"1px solid #94a3b8"}}></span>ガン玉</div>
          <div className="legend-row"><span className="legend-dot" style={{background:"#475569"}}></span>ビシ</div>
        </div>
      </main>

      <div className="controls app__controls">
        {/* グループ1: 投入 / スキップ */}
        <div className="controls__group">
          <button
            className="btn-action btn-cast"
            onClick={() => castRef.current()}
            disabled={phase === "dropping"}
          >投入<small>落とし込み</small></button>
          {phase === "dropping" && (
            <button
              className="btn-action btn-skip"
              onClick={() => skipRef.current()}
            >↯ タナへ<small>即着</small></button>
          )}
        </div>

        {/* グループ2: 竿操作 (主要) */}
        <div className="controls__group">
          <button
            className="btn-action shakuri-btn"
            onClick={() => triggerRef.current()}
            disabled={phase !== "fishing"}
          >しゃくる</button>
          <button
            className="btn-action maki-btn"
            onClick={() => makiRef.current()}
            disabled={phase !== "fishing"}
          >巻く</button>
          <button
            className="btn-action drop-btn"
            onClick={() => dropManualRef.current()}
            disabled={phase !== "fishing"}
          >落とす</button>
        </div>

        {/* グループ3: 回収 */}
        <div className="controls__group">
          <button
            className="btn-action btn-rig"
            onClick={() => retrieveRef.current()}
          >仕掛け回収<small>コマセ補充</small></button>
        </div>

        {/* グループ4: シミュ制御 */}
        <div className="controls__group">
          <button
            className={"btn-action btn-sim " + (running ? "is-running" : "")}
            onClick={() => setRunning(r => !r)}
          >{running ? "一時停止" : "再開"}</button>
          <div className="speed-group" role="group" aria-label="シミュ速度">
            {[1, 2, 3].map(m => (
              <button
                key={m}
                className={"speed-btn" + (speedMul === m ? " is-active" : "")}
                onClick={() => setSpeedMul(m)}
                aria-pressed={speedMul === m}
              >{m}x</button>
            ))}
          </div>
          <button className="btn-action btn-sim" onClick={resetSim}>リセット</button>
        </div>

        {/* グループ5: 状態表示 */}
        <div className="controls__group controls__group--stats">
          <div className="ctl-stat">
            <div className="ctl-stat__label">粒子</div>
            <div className="ctl-stat__value">{particlesRef.current.length}<small>/{MAX_PARTICLES}</small></div>
          </div>
          <div className="ctl-stat">
            <div className="ctl-stat__label">巻き上げ</div>
            <div className="ctl-stat__value">{rigStateRef.current.makiOffset.toFixed(2)}<small>m</small></div>
          </div>
        </div>
      </div>

      <RightPanel
        metrics={grade}
        params={params}
        presets={PRESETS}
        selectedPresets={selectedPresets}
        onPreset={togglePreset}
        onOptimize={runOptimize}
        optimizing={optimizing}
        recommendation={recommendation}
        onApplyRec={applyRecommendation}
        locks={locks}
        lastCycleResult={lastCycleResult}
      />

      {drawerOpen && (
        <>
          <div className="mob-overlay" onClick={() => setDrawerOpen(false)} />
          <div className="mob-drawer">
            <div className="mob-drawer__handle" onClick={() => setDrawerOpen(false)} />
            <div className="mob-drawer__title">設定パネル</div>
            <div className="mob-drawer__tabs">
              <button className={"mob-drawer__tab" + (drawerTab === 0 ? " is-active" : "")} onClick={() => setDrawerTab(0)}>設定</button>
              <button className={"mob-drawer__tab" + (drawerTab === 1 ? " is-active" : "")} onClick={() => setDrawerTab(1)}>診断・最適化</button>
            </div>
            <div className="mob-drawer__body">
              {drawerTab === 0 && <LeftPanel params={params} set={set} locks={locks} toggleLock={toggleLock} />}
              {drawerTab === 1 && <RightPanel metrics={grade} params={params} presets={PRESETS} selectedPresets={selectedPresets} onPreset={togglePreset} onOptimize={runOptimize} optimizing={optimizing} recommendation={recommendation} onApplyRec={applyRecommendation} locks={locks} lastCycleResult={lastCycleResult} />}
            </div>
            {optimizing && (
              <div className="mob-drawer__loading">
                <div className="mob-drawer__loading-text">計算中...</div>
                <div className="mob-drawer__spinner" />
              </div>
            )}
          </div>
        </>
      )}
      <footer className="sim-footer">
        <small>
          参考: TSURINEWS / DAIWA / SHIMANO / サニー商事 / 一之瀬丸 他　|
          教育・設計検証目的・実釣の判断は船長指示に従ってください　|
          © 2026 <a href="/" target="_top">funatsuri-yoso.com</a>　|
          <a href="/komase-sim/" target="_top">紹介ページ</a>
        </small>
      </footer>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
