// ─── 인접 필지 클러스터 탐색 (BFS, 공유 엣지 기반) ─────
function findClusters(parcels) {
  if (!parcels.length) return [];
  const edgeToParcels = new Map();
  parcels.forEach((p, i) => {
    for (let j = 0; j < p.poly.length; j++) {
      const p1 = p.poly[j];
      const p2 = p.poly[(j + 1) % p.poly.length];
      const a = p1[0].toFixed(6) + ',' + p1[1].toFixed(6);
      const b = p2[0].toFixed(6) + ',' + p2[1].toFixed(6);
      const key = a < b ? a + '|' + b : b + '|' + a;
      if (!edgeToParcels.has(key)) edgeToParcels.set(key, []);
      edgeToParcels.get(key).push(i);
    }
  });
  const adj = parcels.map(() => new Set());
  edgeToParcels.forEach(indices => {
    if (indices.length >= 2) {
      for (let a = 0; a < indices.length; a++)
        for (let b = a + 1; b < indices.length; b++) {
          adj[indices[a]].add(indices[b]);
          adj[indices[b]].add(indices[a]);
        }
    }
  });
  const visited = new Array(parcels.length).fill(false);
  const clusters = [];
  for (let i = 0; i < parcels.length; i++) {
    if (visited[i]) continue;
    const cluster = [];
    const queue = [i];
    while (queue.length) {
      const cur = queue.shift();
      if (visited[cur]) continue;
      visited[cur] = true;
      cluster.push(parcels[cur]);
      adj[cur].forEach(nb => { if (!visited[nb]) queue.push(nb); });
    }
    clusters.push(cluster);
  }
  return clusters;
}

// ─── 인접 필지 외곽선 계산 (공유 엣지 제거) ─────
function computeOuterEdges(parcels) {
  const counts = new Map();
  const first = new Map();
  for (const p of parcels) {
    for (let i = 0; i < p.poly.length; i++) {
      const p1 = p.poly[i];
      const p2 = p.poly[(i + 1) % p.poly.length];
      const a = p1[0].toFixed(6) + ',' + p1[1].toFixed(6);
      const b = p2[0].toFixed(6) + ',' + p2[1].toFixed(6);
      const key = a < b ? a + '|' + b : b + '|' + a;
      counts.set(key, (counts.get(key) || 0) + 1);
      if (!first.has(key)) first.set(key, [p1, p2]);
    }
  }
  const outer = [];
  counts.forEach((cnt, key) => { if (cnt === 1) outer.push(first.get(key)); });
  return outer;
}

// ─── GPS 현위치 로컬 스타일 (appStyles 공유 버킷 미사용) ─────
const mapGpsStyles = {
  button: {
    position: 'absolute', right: 12, bottom: 112,
    width: 44, height: 44, borderRadius: 10,
    borderWidth: 1, borderStyle: 'solid', borderColor: '#E4E2DA',
    background: '#FFFFFF', color: '#1A1814',
    display: 'flex', alignItems: 'center', justifyContent: 'center',
    cursor: 'pointer', padding: 0,
    boxShadow: '0 1px 3px rgba(0,0,0,0.05), 0 4px 12px rgba(0,0,0,0.04)',
  },
  buttonFollowing: { background: '#2B7BC9', borderColor: '#2B7BC9', color: '#FFFFFF' },
  buttonFree: { borderColor: '#2B7BC9', color: '#2B7BC9' },
  toast: {
    position: 'absolute', left: '50%', bottom: 24, transform: 'translateX(-50%)',
    maxWidth: '80%', background: 'rgba(26,24,20,0.92)', color: '#FFFFFF',
    padding: '8px 14px', borderRadius: 999, fontSize: 13, fontWeight: 600,
    whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 6,
    boxShadow: '0 4px 12px rgba(0,0,0,0.18)',
  },
};

// ─── 지도 뷰 ───────────────────────────────
function MapView({
  data, overrides, colorById: colorByIdProp, selected, onSelect, fillOpacity,
  groups, parcelToGroup, selectedGroupId,
  multiSelectMode, multiSelected,
  addToGroupMode,
  children,
}) {
  const colorById = colorByIdProp || COLOR_BY_ID;
  const canvasRef = useRef(null);
  const containerRef = useRef(null);
  const labelCanvasRef = useRef(null);

  const transformRef = useRef({ scale: 1, tx: 0, ty: 0 });
  const [transformVersion, setTransformVersion] = useState(0);
  const sizeRef = useRef({ w: 0, h: 0, dpr: 1 });

  const gestureRef = useRef({ mode: null });
  const lastTouchEndRef = useRef(0);
  const onPointerMoveRef = useRef(null);
  const onWheelRef = useRef(null);

  // ─── GPS 현위치 상태 ───────────────────────
  const [gpsState, setGpsState] = useState('off'); // 'off' | 'following' | 'free'
  const [gpsPos, setGpsPos] = useState(null);       // { lng, lat, accuracy }
  const [gpsToast, setGpsToast] = useState(null);
  const gpsStateRef = useRef('off');                // 콜백 스테일 클로저 방지
  useEffect(() => { gpsStateRef.current = gpsState; }, [gpsState]);
  const watchIdRef = useRef(null);
  const toastTimerRef = useRef(null);

  useEffect(() => {
    const cv = canvasRef.current;
    const lcv = labelCanvasRef.current;
    const ct = containerRef.current;
    if (!cv || !ct) return;

    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const fit = () => {
      const r = ct.getBoundingClientRect();
      sizeRef.current = { w: r.width, h: r.height, dpr };
      for (const c of [cv, lcv]) {
        c.width = Math.round(r.width * dpr);
        c.height = Math.round(r.height * dpr);
        c.style.width = r.width + 'px';
        c.style.height = r.height + 'px';
      }
      const aspect = data.aspect;
      const containerAspect = r.width / r.height;
      let scale;
      if (containerAspect > aspect) scale = r.height / 1;
      else scale = r.width / aspect;
      scale *= 0.94;
      const dataW = aspect * scale;
      const dataH = 1 * scale;
      transformRef.current = {
        scale,
        tx: (r.width - dataW) / 2,
        ty: (r.height - dataH) / 2,
      };
      setTransformVersion(v => v + 1);
    };

    fit();
    const ro = new ResizeObserver(fit);
    ro.observe(ct);
    return () => ro.disconnect();
  }, [data]);

  useEffect(() => {
    const ct = containerRef.current;
    if (!ct) return;
    const handleTouchMove = (e) => onPointerMoveRef.current && onPointerMoveRef.current(e);
    const handleWheel = (e) => onWheelRef.current && onWheelRef.current(e);
    ct.addEventListener('touchmove', handleTouchMove, { passive: false });
    ct.addEventListener('wheel', handleWheel, { passive: false });
    return () => {
      ct.removeEventListener('touchmove', handleTouchMove);
      ct.removeEventListener('wheel', handleWheel);
    };
  }, []);

  useEffect(() => {
    const cv = canvasRef.current;
    const lcv = labelCanvasRef.current;
    if (!cv || !lcv) return;
    const ctx = cv.getContext('2d');
    const lctx = lcv.getContext('2d');
    const { w, h, dpr } = sizeRef.current;
    if (!w || !h) return;

    const { scale, tx, ty } = transformRef.current;
    const aspect = data.aspect;
    const dataW = aspect * scale;
    const dataH = 1 * scale;

    // ─ 빠른 룩업 ─
    const parcelById = {};
    for (const p of data.parcels) parcelById[p.id] = p;

    // ─ 그룹별 렌더링 데이터 사전 계산 ─
    const groupRD = {}; // { [groupId]: { memberParcels, outerEdges, colorObj, style } }
    const colorlessGroupRD = {}; // 색상 없는 그룹 { [groupId]: { memberParcels, outerEdges } }
    if (groups && parcelToGroup) {
      for (const [gid, g] of Object.entries(groups)) {
        const memberParcels = (g.parcels || []).map(pid => parcelById[pid]).filter(Boolean);
        if (!memberParcels.length) continue;
        if (g.color) {
          const colorObj = colorById[g.color];
          if (!colorObj) continue;
          groupRD[gid] = {
            memberParcels,
            outerEdges: computeOuterEdges(memberParcels),
            colorObj,
            style: g.style || 'fill',
          };
        } else {
          colorlessGroupRD[gid] = {
            memberParcels,
            outerEdges: computeOuterEdges(memberParcels),
          };
        }
      }
    }

    // ─ 폴리곤 레이어 ─
    ctx.save();
    ctx.scale(dpr, dpr);
    ctx.clearRect(0, 0, w, h);
    ctx.fillStyle = '#FBFAF6';
    ctx.fillRect(0, 0, w, h);
    ctx.lineJoin = 'round';
    ctx.lineCap = 'round';

    const drawPath = (poly) => {
      ctx.beginPath();
      for (let i = 0; i < poly.length; i++) {
        const [x, y] = poly[i];
        const px = tx + x * dataW;
        const py = ty + y * dataH;
        if (i === 0) ctx.moveTo(px, py);
        else ctx.lineTo(px, py);
      }
      ctx.closePath();
    };

    // compound path로 채우기 (그룹 통합 렌더링용)
    const drawCompoundFill = (memberParcels, fillStyle) => {
      ctx.beginPath();
      for (const p of memberParcels) {
        for (let i = 0; i < p.poly.length; i++) {
          const [x, y] = p.poly[i];
          const px = tx + x * dataW;
          const py = ty + y * dataH;
          if (i === 0) ctx.moveTo(px, py);
          else ctx.lineTo(px, py);
        }
        ctx.closePath();
      }
      ctx.fillStyle = fillStyle;
      ctx.fill();
    };

    // 외곽선 세그먼트 그리기 (공유 엣지 제외)
    const drawOuterEdges = (outerEdges, strokeStyle, lineWidth, lineDash) => {
      ctx.strokeStyle = strokeStyle;
      ctx.lineWidth = lineWidth;
      if (lineDash) ctx.setLineDash(lineDash);
      for (const [p1, p2] of outerEdges) {
        ctx.beginPath();
        ctx.moveTo(tx + p1[0] * dataW, ty + p1[1] * dataH);
        ctx.lineTo(tx + p2[0] * dataW, ty + p2[1] * dataH);
        ctx.stroke();
      }
      if (lineDash) ctx.setLineDash([]);
    };

    // 1차: 기본(미지정) 폴리곤 — 그룹 멤버·개별 색상 필지 제외
    for (const p of data.parcels) {
      const gid = parcelToGroup && parcelToGroup[p.id];
      if (gid && (groupRD[gid] || colorlessGroupRD[gid])) continue; // 그룹 멤버는 이후 패스에서 처리
      const ov = overrides[p.id];
      if (ov && ov.color) continue;         // 개별 색상 있는 것은 2차에서 처리
      drawPath(p.poly);
      ctx.fillStyle = '#FFFFFF';
      ctx.fill();
      ctx.strokeStyle = '#C9C4B6';
      ctx.lineWidth = 0.6;
      ctx.stroke();
    }

    // 1.5차: 색상 없는 그룹 — 흰 배경 + 점선 외곽선으로 그룹 경계 표시
    for (const { memberParcels, outerEdges } of Object.values(colorlessGroupRD)) {
      drawCompoundFill(memberParcels, '#FFFFFF');
      drawOuterEdges(outerEdges, 'rgba(90, 110, 190, 0.55)', 1.8, [6, 4]);
    }

    // 2차: 개별 색상 지정 폴리곤 (그룹 미소속)
    for (const p of data.parcels) {
      const gid = parcelToGroup && parcelToGroup[p.id];
      if (gid) continue; // 그룹 소속은 3차에서 처리
      const ov = overrides[p.id];
      if (!ov || !ov.color) continue;
      const colorObj = colorById[ov.color];
      if (!colorObj) continue;
      const style = ov.style || 'fill';
      drawPath(p.poly);
      if (style === 'fill') {
        ctx.fillStyle = hexA(colorObj.hex, fillOpacity);
        ctx.fill();
        ctx.strokeStyle = colorObj.hex;
        ctx.lineWidth = 1.4;
        ctx.stroke();
      } else {
        ctx.fillStyle = '#FFFFFF';
        ctx.fill();
        ctx.strokeStyle = colorObj.hex;
        ctx.lineWidth = 2.6;
        ctx.stroke();
      }
    }

    // 3차: 그룹 통합 렌더링 (인접 필지 공유 경계 제거)
    for (const { memberParcels, outerEdges, colorObj, style } of Object.values(groupRD)) {
      if (style === 'fill') {
        drawCompoundFill(memberParcels, hexA(colorObj.hex, fillOpacity));
      } else {
        drawCompoundFill(memberParcels, '#FFFFFF');
      }
      drawOuterEdges(outerEdges, colorObj.hex, style === 'fill' ? 1.4 : 2.6);
    }

    // 4차: 단일 필지 선택 강조
    if (selected) {
      const p = parcelById[selected];
      if (p) {
        drawPath(p.poly);
        const gid = parcelToGroup && parcelToGroup[p.id];
        const ov = gid ? (groups && groups[gid]) : overrides[p.id];
        const colorObj = ov && ov.color ? colorById[ov.color] : null;
        const style = ov && ov.style ? ov.style : 'fill';
        if (colorObj && style === 'fill') {
          ctx.fillStyle = hexA(colorObj.hex, Math.min(fillOpacity + 0.2, 0.9));
          ctx.fill();
        } else if (!colorObj) {
          ctx.fillStyle = 'rgba(47, 125, 79, 0.18)';
          ctx.fill();
        }
        ctx.strokeStyle = '#1F5A38';
        ctx.lineWidth = 3;
        ctx.stroke();
      }
    }

    // 5차: 그룹 선택 강조 (통합 외곽선)
    if (selectedGroupId && groups && groups[selectedGroupId]) {
      const g = groups[selectedGroupId];
      const rd = groupRD[selectedGroupId] || colorlessGroupRD[selectedGroupId];
      const memberParcels = rd
        ? rd.memberParcels
        : (g.parcels || []).map(pid => parcelById[pid]).filter(Boolean);

      if (memberParcels.length > 0) {
        const outerEdges = rd ? rd.outerEdges : computeOuterEdges(memberParcels);
        const colorObj = g.color ? colorById[g.color] : null;
        if (colorObj && (g.style || 'fill') === 'fill') {
          drawCompoundFill(memberParcels, hexA(colorObj.hex, Math.min(fillOpacity + 0.2, 0.9)));
        } else if (!colorObj) {
          drawCompoundFill(memberParcels, 'rgba(47, 125, 79, 0.18)');
        }
        drawOuterEdges(outerEdges, '#1F5A38', 3);
      }
    }

    // 6차: 멀티 선택 강조
    if (multiSelectMode) {
      // 6-1: 기존 그룹 소속 필지 — 탭 가능 힌트 (연한 파란 테두리)
      if (groups) {
        const selectedSet = new Set(multiSelected || []);
        for (const [, g] of Object.entries(groups)) {
          for (const pid of (g.parcels || [])) {
            if (selectedSet.has(pid)) continue; // 이미 선택된 건 6-2에서 처리
            const p = parcelById[pid];
            if (!p) continue;
            drawPath(p.poly);
            ctx.strokeStyle = 'rgba(59, 130, 246, 0.6)';
            ctx.lineWidth = 2;
            ctx.stroke();
          }
        }
      }
      // 6-2: 선택된 필지 초록 오버레이
      if (multiSelected && multiSelected.length > 0) {
        for (const pid of multiSelected) {
          const p = parcelById[pid];
          if (!p) continue;
          drawPath(p.poly);
          ctx.fillStyle = 'rgba(47, 125, 79, 0.25)';
          ctx.fill();
          ctx.strokeStyle = '#2F7D4F';
          ctx.lineWidth = 3;
          ctx.stroke();
        }
      }
    }

    // 7차: 필지 추가 중인 그룹 멤버 강조 (점선 테두리)
    if (addToGroupMode && groups && groups[addToGroupMode]) {
      const g = groups[addToGroupMode];
      for (const pid of (g.parcels || [])) {
        const p = parcelById[pid];
        if (!p) continue;
        drawPath(p.poly);
        ctx.fillStyle = 'rgba(47, 125, 79, 0.30)';
        ctx.fill();
        ctx.strokeStyle = '#1F5A38';
        ctx.lineWidth = 3;
        ctx.setLineDash([6, 4]);
        ctx.stroke();
        ctx.setLineDash([]);
      }
    }

    ctx.restore();

    // ─ 라벨 레이어 ─
    lctx.save();
    lctx.scale(dpr, dpr);
    lctx.clearRect(0, 0, w, h);

    const FONT_SIZE = 11;
    const LINE_HEIGHT = 13;
    const PADDING = 4;

    lctx.font = `600 ${FONT_SIZE}px Pretendard, -apple-system, system-ui, sans-serif`;
    lctx.textAlign = 'center';
    lctx.textBaseline = 'middle';

    const minBoxW = 14;
    const minBoxH = FONT_SIZE + 2;

    // 그룹에 속한 필지는 개별 라벨 생략 (그룹명을 별도 렌더링)
    const parcelInNamedGroup = new Set();
    if (groups && parcelToGroup) {
      for (const [gid, g] of Object.entries(groups)) {
        if (!g.name) continue;
        (g.parcels || []).forEach(pid => parcelInNamedGroup.add(pid));
      }
    }

    // 개별 필지 라벨 (그룹 이름 있는 멤버 제외)
    for (const p of data.parcels) {
      if (parcelInNamedGroup.has(p.id)) continue;

      const gid = parcelToGroup && parcelToGroup[p.id];
      const g = gid && groups && groups[gid];
      const ov = overrides[p.id];

      const name = (ov && ov.name) || p.jibun;
      if (!name) continue;

      const cxPx = tx + p.cx * dataW;
      const cyPx = ty + p.cy * dataH;
      if (cxPx < -40 || cyPx < -20 || cxPx > w + 40 || cyPx > h + 20) continue;

      const boxW = p.bw * dataW;
      const boxH = p.bh * dataH;
      if (boxW < minBoxW || boxH < minBoxH) continue;

      const maxLineWidth = Math.max(8, boxW - PADDING * 2);
      const maxLines = Math.max(1, Math.floor((boxH - PADDING * 2 + LINE_HEIGHT - FONT_SIZE) / LINE_HEIGHT));

      const lines = wrapText(lctx, name, maxLineWidth, maxLines);
      if (lines.length === 0) continue;

      const isCustom = (ov && ov.name);
      const isColored = (g && g.color) || (ov && ov.color);

      const totalH = lines.length * LINE_HEIGHT - (LINE_HEIGHT - FONT_SIZE);
      const startY = cyPx - totalH / 2 + FONT_SIZE / 2;

      lctx.lineWidth = 2.5;
      lctx.strokeStyle = 'rgba(255,255,255,0.92)';
      lctx.fillStyle = isCustom ? '#1A1814' : isColored ? '#3A3631' : '#5C5851';

      for (let i = 0; i < lines.length; i++) {
        const ly = startY + i * LINE_HEIGHT;
        lctx.strokeText(lines[i], cxPx, ly);
        lctx.fillText(lines[i], cxPx, ly);
      }

      // 고정 필지 이모지 아이콘 (이름 위에 표시) — 그룹 소속 여부와 무관하게 개별 override 참조
      const individualOv = overrides[p.id];
      if (individualOv && individualOv.pinned && individualOv.icon) {
        const iconSize = Math.max(12, Math.min(22, boxW * 0.38));
        lctx.font = `${iconSize}px serif`;
        lctx.textAlign = 'center';
        lctx.textBaseline = 'middle';
        const textTotalH = lines.length * LINE_HEIGHT - (LINE_HEIGHT - FONT_SIZE);
        const iconY = cyPx - textTotalH / 2 - iconSize * 0.7;
        lctx.fillText(individualOv.icon, cxPx, iconY);
        // 폰트 복원
        lctx.font = `600 ${FONT_SIZE}px Pretendard, -apple-system, system-ui, sans-serif`;
        lctx.textAlign = 'center';
        lctx.textBaseline = 'middle';
      }
    }

    // 그룹명 라벨 — 인접 클러스터별로 각각 표시
    if (groups) {
      for (const [gid, g] of Object.entries(groups)) {
        if (!g.name) continue;
        const members = (g.parcels || []).map(pid => parcelById[pid]).filter(Boolean);
        if (!members.length) continue;

        const clusters = findClusters(members);
        for (const cluster of clusters) {
          let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
          for (const p of cluster) {
            for (const [x, y] of p.poly) {
              const px = tx + x * dataW;
              const py = ty + y * dataH;
              if (px < minX) minX = px; if (px > maxX) maxX = px;
              if (py < minY) minY = py; if (py > maxY) maxY = py;
            }
          }

          const cxPx = (minX + maxX) / 2;
          const cyPx = (minY + maxY) / 2;
          if (cxPx < -40 || cyPx < -20 || cxPx > w + 40 || cyPx > h + 20) continue;

          const boxW = maxX - minX;
          const boxH = maxY - minY;
          if (boxW < minBoxW || boxH < minBoxH) continue;

          const maxLineWidth = Math.max(8, boxW - PADDING * 2);
          const maxLines = Math.max(1, Math.floor((boxH - PADDING * 2 + LINE_HEIGHT - FONT_SIZE) / LINE_HEIGHT));

          const lines = wrapText(lctx, g.name, maxLineWidth, maxLines);
          if (lines.length === 0) continue;

          const totalH = lines.length * LINE_HEIGHT - (LINE_HEIGHT - FONT_SIZE);
          const startY = cyPx - totalH / 2 + FONT_SIZE / 2;

          lctx.lineWidth = 2.5;
          lctx.strokeStyle = 'rgba(255,255,255,0.92)';
          lctx.fillStyle = '#1A1814';

          for (let i = 0; i < lines.length; i++) {
            const ly = startY + i * LINE_HEIGHT;
            lctx.strokeText(lines[i], cxPx, ly);
            lctx.fillText(lines[i], cxPx, ly);
          }
        }
      }
    }
    // ─ GPS 현위치 점 ─
    if (gpsState !== 'off' && gpsPos && data.project) {
      const [gnx, gny] = data.project(gpsPos.lng, gpsPos.lat);
      const gpx = tx + gnx * dataW;
      const gpy = ty + gny * dataH;
      if (gpx >= -20 && gpy >= -20 && gpx <= w + 20 && gpy <= h + 20) {
        lctx.beginPath();
        lctx.arc(gpx, gpy, 16, 0, Math.PI * 2);
        lctx.fillStyle = 'rgba(43, 123, 201, 0.15)'; // 정확도 링
        lctx.fill();
        lctx.beginPath();
        lctx.arc(gpx, gpy, 7, 0, Math.PI * 2);
        lctx.fillStyle = '#FFFFFF';                   // 흰 테두리
        lctx.fill();
        lctx.beginPath();
        lctx.arc(gpx, gpy, 5, 0, Math.PI * 2);
        lctx.fillStyle = '#2B7BC9';                   // 파란 점
        lctx.fill();
      }
    }

    lctx.restore();
  }, [data, overrides, colorById, selected, fillOpacity, transformVersion,
      groups, parcelToGroup, selectedGroupId, multiSelectMode, multiSelected, addToGroupMode,
      gpsState, gpsPos]);

  // ─ 제스처 ─
  const screenToData = useCallback((sx, sy) => {
    const { scale, tx, ty } = transformRef.current;
    const aspect = data.aspect;
    return [(sx - tx) / (aspect * scale), (sy - ty) / (1 * scale)];
  }, [data]);

  const hitTest = useCallback((sx, sy, transform) => {
    const { scale, tx, ty } = transform || transformRef.current;
    const aspect = data.aspect;
    const dx = (sx - tx) / (aspect * scale);
    const dy = (sy - ty) / scale;
    if (dx < 0 || dx > data.aspect || dy < 0 || dy > 1) return null;
    for (let i = data.parcels.length - 1; i >= 0; i--) {
      const p = data.parcels[i];
      if (pointInPolygon(dx, dy, p.poly)) return p.id;
    }
    return null;
  }, [data]);

  const onPointerStart = (e) => {
    if (!e.touches && Date.now() - lastTouchEndRef.current < 600) return;
    const touches = e.touches ? Array.from(e.touches) : [{ clientX: e.clientX, clientY: e.clientY }];
    const ct = containerRef.current.getBoundingClientRect();
    if (touches.length === 1) {
      gestureRef.current = {
        mode: 'tap',
        moved: false,
        tapStart: {
          x: touches[0].clientX - ct.left,
          y: touches[0].clientY - ct.top,
          clientX: touches[0].clientX,
          clientY: touches[0].clientY,
          t: Date.now(),
        },
        startTransform: { ...transformRef.current },
      };
      const g = gestureRef.current;
      console.log(`[tap-start] x=${g.tapStart.x.toFixed(1)} y=${g.tapStart.y.toFixed(1)} scale=${g.startTransform.scale.toFixed(1)}`);
    } else if (touches.length === 2) {
      const t0 = touches[0], t1 = touches[1];
      const cx = (t0.clientX + t1.clientX) / 2 - ct.left;
      const cy = (t0.clientY + t1.clientY) / 2 - ct.top;
      const dist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY);
      gestureRef.current = {
        mode: 'pinch', startDist: dist,
        startCenter: { x: cx, y: cy },
        startTransform: { ...transformRef.current }, moved: true,
      };
    }
  };

  const onPointerMove = (e) => {
    const g = gestureRef.current;
    if (!g.mode) return;
    const touches = e.touches ? Array.from(e.touches) : [{ clientX: e.clientX, clientY: e.clientY }];
    const ct = containerRef.current.getBoundingClientRect();

    if (touches.length === 2 && g.mode !== 'pinch') {
      const t0 = touches[0], t1 = touches[1];
      const cx = (t0.clientX + t1.clientX) / 2 - ct.left;
      const cy = (t0.clientY + t1.clientY) / 2 - ct.top;
      const dist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY);
      gestureRef.current = {
        mode: 'pinch', startDist: dist,
        startCenter: { x: cx, y: cy },
        startTransform: { ...transformRef.current }, moved: true,
      };
      return;
    }

    if (g.mode === 'pinch' && touches.length >= 2) {
      demoteToFree();
      const t0 = touches[0], t1 = touches[1];
      const cx = (t0.clientX + t1.clientX) / 2 - ct.left;
      const cy = (t0.clientY + t1.clientY) / 2 - ct.top;
      const dist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY);
      const ratio = dist / g.startDist;
      const start = g.startTransform;
      const newScale = Math.max(50, Math.min(start.scale * ratio, 30000));
      const aspect = data.aspect;
      const dataX = (g.startCenter.x - start.tx) / (aspect * start.scale);
      const dataY = (g.startCenter.y - start.ty) / (1 * start.scale);
      transformRef.current = {
        scale: newScale,
        tx: cx - dataX * aspect * newScale,
        ty: cy - dataY * 1 * newScale,
      };
      setTransformVersion(v => v + 1);
      e.preventDefault();
      return;
    }

    if (g.mode === 'tap' || g.mode === 'pan') {
      const t = touches[0];
      const dx = e.touches
        ? t.clientX - g.tapStart.clientX
        : (t.clientX - ct.left) - g.tapStart.x;
      const dy = e.touches
        ? t.clientY - g.tapStart.clientY
        : (t.clientY - ct.top) - g.tapStart.y;
      const tapThreshold = e.touches ? 12 : 6;
      if (!g.moved && Math.hypot(dx, dy) > tapThreshold) {
        g.mode = 'pan'; g.moved = true;
        demoteToFree();
      }
      if (g.mode === 'pan') {
        const start = g.startTransform;
        transformRef.current = { scale: start.scale, tx: start.tx + dx, ty: start.ty + dy };
        setTransformVersion(v => v + 1);
        if (!e.touches) e.preventDefault();
      }
    }
  };

  const onPointerEnd = (e) => {
    const g = gestureRef.current;
    if (!g.mode) return;
    if (g.mode === 'tap' && !g.moved && g.tapStart) {
      const dt = Date.now() - g.tapStart.t;
      console.log(`[tap-end] dt=${dt}ms x=${g.tapStart.x.toFixed(1)} y=${g.tapStart.y.toFixed(1)}`);
      if (dt < 500) {
        const id = hitTest(g.tapStart.x, g.tapStart.y, g.startTransform);
        console.log(`[hitTest] id=${id} (type=${typeof id})`);
        try {
          if (id) { console.log(`[onSelect] 호출 id=${id}`); onSelect(id); }
          else { console.log('[onSelect] null 호출'); onSelect(null); }
        } catch (err) {
          console.error('[onSelect] 예외: ' + err.message);
        }
      } else {
        console.log('[tap-end] 500ms 초과로 무시됨');
      }
    } else {
      console.log(`[tap-end] 무시: mode=${g.mode} moved=${g.moved}`);
    }
    const remaining = e.touches ? e.touches.length : 0;
    if (remaining === 1 && g.mode === 'pinch') {
      const ct = containerRef.current.getBoundingClientRect();
      const t = e.touches[0];
      gestureRef.current = {
        mode: 'pan', moved: true,
        tapStart: { x: t.clientX - ct.left, y: t.clientY - ct.top, clientX: t.clientX, clientY: t.clientY, t: Date.now() },
        startTransform: { ...transformRef.current },
      };
    } else if (remaining === 0) {
      gestureRef.current = { mode: null };
    }
  };

  const onWheel = (e) => {
    e.preventDefault();
    demoteToFree();
    const ct = containerRef.current.getBoundingClientRect();
    const cx = e.clientX - ct.left;
    const cy = e.clientY - ct.top;
    const start = transformRef.current;
    const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
    const newScale = Math.max(50, Math.min(start.scale * factor, 30000));
    const aspect = data.aspect;
    const dataX = (cx - start.tx) / (aspect * start.scale);
    const dataY = (cy - start.ty) / (1 * start.scale);
    transformRef.current = {
      scale: newScale,
      tx: cx - dataX * aspect * newScale,
      ty: cy - dataY * 1 * newScale,
    };
    setTransformVersion(v => v + 1);
  };

  const zoom = (factor) => {
    const ct = containerRef.current.getBoundingClientRect();
    const cx = ct.width / 2;
    const cy = ct.height / 2;
    const start = transformRef.current;
    const newScale = Math.max(50, Math.min(start.scale * factor, 30000));
    const aspect = data.aspect;
    const dataX = (cx - start.tx) / (aspect * start.scale);
    const dataY = (cy - start.ty) / (1 * start.scale);
    transformRef.current = {
      scale: newScale,
      tx: cx - dataX * aspect * newScale,
      ty: cy - dataY * 1 * newScale,
    };
    setTransformVersion(v => v + 1);
  };

  // ─── GPS 현위치 로직 ───────────────────────
  const showToast = (msg) => {
    setGpsToast(msg);
    if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
    toastTimerRef.current = setTimeout(() => setGpsToast(null), 2800);
  };

  // 정규화 좌표 (nx,ny)를 화면 중앙으로 (scale 유지, 평행이동) — zoom()과 동일 패턴
  const recenterTo = (nx, ny) => {
    const ct = containerRef.current;
    if (!ct) return;
    const r = ct.getBoundingClientRect();
    const { scale } = transformRef.current;
    const dataW = data.aspect * scale;
    const dataH = scale;
    transformRef.current = { scale, tx: r.width / 2 - nx * dataW, ty: r.height / 2 - ny * dataH };
    setTransformVersion(v => v + 1);
  };

  // following 상태에서 사용자가 지도를 움직이면 추적은 유지하되 자동 중심 해제
  const demoteToFree = () => {
    if (gpsStateRef.current === 'following') {
      setGpsState('free');
      gpsStateRef.current = 'free';
    }
  };

  const isOutOfBounds = (nx, ny) => nx < -0.02 || nx > 1.02 || ny < -0.02 || ny > 1.02;

  const onGpsPos = (position) => {
    const { longitude, latitude, accuracy } = position.coords;
    setGpsPos({ lng: longitude, lat: latitude, accuracy });
    if (!data.project) return;
    const [nx, ny] = data.project(longitude, latitude);
    if (isOutOfBounds(nx, ny)) {
      showToast('현위치가 지도 범위를 벗어났습니다');
      return; // 자동 중심 이동 생략
    }
    if (gpsStateRef.current === 'following') recenterTo(nx, ny);
  };

  const onGpsErr = (err) => {
    if (err && err.code === 1) { // PERMISSION_DENIED
      stopWatch();
      setGpsState('off'); gpsStateRef.current = 'off';
      setGpsPos(null);
      showToast('위치 권한이 거부되었습니다');
    }
    // timeout/position-unavailable 등은 무시하고 다음 fix 대기
  };

  function stopWatch() {
    if (watchIdRef.current != null && navigator.geolocation) {
      navigator.geolocation.clearWatch(watchIdRef.current);
    }
    watchIdRef.current = null;
  }

  function startWatch() {
    if (!navigator.geolocation) {
      showToast('이 기기에서는 위치를 사용할 수 없습니다');
      setGpsState('off'); gpsStateRef.current = 'off';
      return;
    }
    if (watchIdRef.current != null) return; // 중복 등록 방지
    watchIdRef.current = navigator.geolocation.watchPosition(onGpsPos, onGpsErr, {
      enableHighAccuracy: true, maximumAge: 2000, timeout: 10000,
    });
  }

  const toggleGps = () => {
    const s = gpsStateRef.current;
    if (s === 'off') {
      setGpsState('following'); gpsStateRef.current = 'following';
      startWatch(); // 첫 fix에서 현위치로 중심 이동
    } else if (s === 'following') {
      stopWatch();
      setGpsState('off'); gpsStateRef.current = 'off';
      setGpsPos(null);
    } else { // free → 현위치로 재중심 (추적 재개)
      setGpsState('following'); gpsStateRef.current = 'following';
      if (gpsPos && data.project) {
        const [nx, ny] = data.project(gpsPos.lng, gpsPos.lat);
        if (isOutOfBounds(nx, ny)) showToast('현위치가 지도 범위를 벗어났습니다');
        else recenterTo(nx, ny);
      }
    }
  };

  // 배터리 절약: 앱 비활성화 시 watch 일시정지, 복귀 시 재개. 언마운트 시 정리.
  useEffect(() => {
    const onVis = () => {
      if (document.hidden) {
        if (watchIdRef.current != null) stopWatch();
      } else if (gpsStateRef.current !== 'off') {
        startWatch();
      }
    };
    document.addEventListener('visibilitychange', onVis);
    return () => {
      document.removeEventListener('visibilitychange', onVis);
      stopWatch();
      if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
    };
  }, []);

  onPointerMoveRef.current = onPointerMove;
  onWheelRef.current = onWheel;

  return (
    <div
      ref={containerRef}
      style={appStyles.mapContainer}
      onTouchStart={onPointerStart}
      onTouchEnd={e => { lastTouchEndRef.current = Date.now(); onPointerEnd(e); }}
      onTouchCancel={e => { lastTouchEndRef.current = Date.now(); onPointerEnd(e); }}
      onMouseDown={onPointerStart}
      onMouseMove={(e) => { if (gestureRef.current.mode) onPointerMove(e); }}
      onMouseUp={onPointerEnd}
      onMouseLeave={onPointerEnd}
    >
      <canvas ref={canvasRef} style={appStyles.mapCanvas} />
      <canvas ref={labelCanvasRef} style={appStyles.mapCanvas} />

      <div style={appStyles.zoomControls}>
        <IconButton icon="plus" onClick={() => zoom(1.6)} label="확대" extraStyle={appStyles.zoomBtn} />
        <div style={appStyles.zoomDivider}></div>
        <IconButton icon="minus" onClick={() => zoom(1 / 1.6)} label="축소" extraStyle={appStyles.zoomBtn} />
      </div>

      {/* GPS 현위치 버튼 (raw button — 지도 제스처 전파 차단 필요) */}
      <button
        style={{
          ...mapGpsStyles.button,
          ...(gpsState === 'following' ? mapGpsStyles.buttonFollowing : {}),
          ...(gpsState === 'free' ? mapGpsStyles.buttonFree : {}),
        }}
        onMouseDown={e => e.stopPropagation()}
        onMouseUp={e => e.stopPropagation()}
        onTouchStart={e => e.stopPropagation()}
        onTouchEnd={e => e.stopPropagation()}
        onClick={(e) => { e.stopPropagation(); toggleGps(); }}
        aria-label="현위치"
      >
        {/* 아이콘 고정 (lucide+React removeChild 충돌 방지). 상태는 버튼 배경/색으로 구분.
            아이콘 색은 button color를 currentColor로 상속. */}
        <i data-lucide="locate" style={{ width: 22, height: 22 }}></i>
      </button>

      {gpsToast && <div style={mapGpsStyles.toast}>{gpsToast}</div>}

      {children}
    </div>
  );
}
