// 지적편집도 모바일 메모 앱 v2
// 변경:
// - 라벨: 확대 없이도 거의 모든 지번 표시 (작은 폴리곤만 생략)
// - 영속화: localStorage 제거. JSON 내보내기/불러오기로 3인 공유.
// - 지번별 스타일: 테두리 / 채움 선택
// - 색상 이름 사용자 편집 (설정에서)

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

const CLIENT_ID = Math.random().toString(36).slice(2, 8);

let supabaseClient = null;

// ─── 디버그 콘솔 인터셉터 ────────────────────
const _dbgLogs = [];
const _dbgListeners = new Set();
(function () {
  ['log', 'warn', 'error'].forEach(function (m) {
    const orig = console[m].bind(console);
    console[m] = function (...args) {
      orig(...args);
      const text = args.map(a => {
        try { return typeof a === 'object' ? JSON.stringify(a) : String(a); } catch (e) { return String(a); }
      }).join(' ');
      _dbgLogs.push({ level: m, text, t: Date.now() });
      if (_dbgLogs.length > 300) _dbgLogs.shift();
      _dbgListeners.forEach(fn => fn());
    };
  });
  window.onerror = function (msg, src, line, col, err) {
    console.error('[onerror] ' + msg + ' @ ' + (src || '') + ':' + line);
  };
}());

// ─── 색상 팔레트 (기본값) ─────────────────────
const COLORS = [
  { id: 'eco',    defaultLabel: '친환경', value: '#6CA945' },
  { id: 'sun',    defaultLabel: '관행',   value: '#E5A300' },
  { id: 'sky',    defaultLabel: '파랑',   value: '#2B7BC9' },
  { id: 'rose',   defaultLabel: '빨강',   value: '#C8392E' },
  { id: 'plum',   defaultLabel: '보라',   value: '#8B5CF6' },
  { id: 'soil',   defaultLabel: '갈색',   value: '#8C6B3F' },
];
const COLOR_BY_ID = Object.fromEntries(COLORS.map(c => [c.id, c]));

const STYLE_OPTIONS = [
  { id: 'fill',   label: '채움' },
  { id: 'border', label: '테두리' },
];

// ─── 좌표 투영 ─────────────────────────────
function makeProjector(bbox) {
  const [minLng, minLat, maxLng, maxLat] = bbox;
  const cLat = (minLat + maxLat) / 2;
  const cosCLat = Math.cos((cLat * Math.PI) / 180);
  const w = (maxLng - minLng) * cosCLat;
  const h = (maxLat - minLat);
  return {
    aspect: w / h,
    project(lng, lat) {
      return [
        ((lng - minLng) * cosCLat) / w,
        1 - (lat - minLat) / h,
      ];
    },
  };
}

// ─── point-in-polygon ─────────────────────
function pointInPolygon(x, y, poly) {
  let inside = false;
  for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
    const [xi, yi] = poly[i];
    const [xj, yj] = poly[j];
    const intersect =
      yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
    if (intersect) inside = !inside;
  }
  return inside;
}

function polyCentroid(poly) {
  let cx = 0, cy = 0, a = 0;
  for (let i = 0, n = poly.length; i < n; i++) {
    const [x0, y0] = poly[i];
    const [x1, y1] = poly[(i + 1) % n];
    const f = x0 * y1 - x1 * y0;
    cx += (x0 + x1) * f;
    cy += (y0 + y1) * f;
    a += f;
  }
  if (Math.abs(a) < 1e-12) {
    let sx = 0, sy = 0;
    for (const [x, y] of poly) { sx += x; sy += y; }
    return [sx / poly.length, sy / poly.length];
  }
  a *= 0.5;
  return [cx / (6 * a), cy / (6 * a)];
}

function polyArea(poly) {
  let a = 0;
  for (let i = 0, n = poly.length; i < n; i++) {
    const [x0, y0] = poly[i];
    const [x1, y1] = poly[(i + 1) % n];
    a += x0 * y1 - x1 * y0;
  }
  return Math.abs(a) * 0.5;
}

// ─── 메인 앱 ───────────────────────────────
function App() {
  const [data, setData] = useState(null);

  const [overrides, setOverrides] = useState({});
  const [colorLabels, setColorLabels] = useState(
    Object.fromEntries(COLORS.map(c => [c.id, c.defaultLabel]))
  );
  const [lastSyncedAt, setLastSyncedAt] = useState(null);

  const overridesRef = useRef({});
  useEffect(() => { overridesRef.current = overrides; }, [overrides]);
  const lastLocalChangeAt = useRef(0);
  const [supabaseReady, setSupabaseReady] = useState(false);

  const [selected, setSelected] = useState(null);
  const [view, setView] = useState(null); // 'settings' | 'share' | null

  useEffect(() => {
    if (!data) return;
    console.log(`[selected] → ${selected} (type=${typeof selected})`);
    if (selected !== null) {
      const idType = data.parcels[0] ? typeof data.parcels[0].id : 'n/a';
      const found = data.parcels.find(p => p.id === selected);
      console.log(`[parcel-find] parcels[0].id type=${idType}, found=${found ? found.jibun : 'null ← 타입 불일치 의심'}`);
    }
  }, [selected, data]);

  const [tweaks, setTweak] = window.useTweaks
    ? window.useTweaks(/*EDITMODE-BEGIN*/{
        "fillOpacity": 0.55
      }/*EDITMODE-END*/)
    : [{ fillOpacity: 0.55 }, () => {}];

  useEffect(() => {
    Promise.all([
      fetch('data/parcels.json').then(r => r.json()),
      fetch('/api/config').then(r => r.json()).catch(() => ({})),
      fetch('/api/state').then(r => r.json()).catch(() => ({ parcels: {}, colorLabels: {} })),
    ]).then(([d, cfg, savedState]) => {
      if (cfg.supabaseUrl && cfg.supabaseAnonKey) {
        supabaseClient = window.supabase.createClient(cfg.supabaseUrl, cfg.supabaseAnonKey);
      }
      const proj = makeProjector(d.bbox);
      const parcels = d.parcels.map(p => {
        const projected = p.c.map(([lng, lat]) => proj.project(lng, lat));
        const [cx, cy] = polyCentroid(projected);
        let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
        for (const [x, y] of projected) {
          if (x < minX) minX = x;
          if (x > maxX) maxX = x;
          if (y < minY) minY = y;
          if (y > maxY) maxY = y;
        }
        return {
          id: p.id,
          jibun: p.jibun,
          poly: projected,
          cx, cy,
          bx: minX, by: minY,
          bw: maxX - minX, bh: maxY - minY,
          area: polyArea(projected),
        };
      });
      parcels.sort((a, b) => b.area - a.area);
      setData({ aspect: proj.aspect, parcels });
      if (savedState.parcels && Object.keys(savedState.parcels).length > 0) {
        setOverrides(savedState.parcels);
      }
      if (savedState.colorLabels && Object.keys(savedState.colorLabels).length > 0) {
        setColorLabels(prev => ({ ...prev, ...savedState.colorLabels }));
      }
      setSupabaseReady(true);
    });
  }, []);

  const updateOverride = useCallback((id, patch) => {
    const cur = overridesRef.current[id] || {};
    const next = { ...cur, ...patch };
    if (next.name === '') delete next.name;
    if (next.color == null) { delete next.color; delete next.style; }
    const override = Object.keys(next).length > 0 ? next : null;

    setOverrides(prev => {
      const out = { ...prev };
      if (!override) delete out[id];
      else out[id] = override;
      return out;
    });

    lastLocalChangeAt.current = Date.now();
    fetch(`/api/parcels/${id}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        color: override ? (override.color || null) : null,
        style: override ? (override.style || null) : null,
        name:  override ? (override.name  || null) : null,
        clientId: CLIENT_ID,
      }),
    }).catch(() => {});
  }, []);

  // ─── 내보내기 / 불러오기 ───
  const exportJSON = useCallback(() => {
    const payload = {
      version: 1,
      exportedAt: new Date().toISOString(),
      colorLabels,
      parcels: overrides,
    };
    const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    const date = new Date().toISOString().slice(0, 10);
    a.href = url;
    a.download = `보구곶리_지번_${date}.json`;
    a.click();
    URL.revokeObjectURL(url);
    setLastSyncedAt(new Date());
  }, [overrides, colorLabels]);

  const importJSON = useCallback((file) => {
    const reader = new FileReader();
    reader.onload = () => {
      try {
        const obj = JSON.parse(reader.result);
        const parcels = obj.parcels || {};
        const colorLabels = obj.colorLabels || {};
        if (obj.parcels) setOverrides(parcels);
        if (obj.colorLabels) setColorLabels(prev => ({ ...prev, ...colorLabels }));
        setLastSyncedAt(new Date());

        lastLocalChangeAt.current = Date.now();
        fetch('/api/state', {
          method: 'PUT',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ parcels, colorLabels, clientId: CLIENT_ID }),
        }).catch(() => {});
      } catch (e) {
        alert('JSON 파일을 읽을 수 없습니다.');
      }
    };
    reader.readAsText(file);
  }, []);

  const updateColorLabels = useCallback((newLabels) => {
    setColorLabels(newLabels);
    lastLocalChangeAt.current = Date.now();
    fetch('/api/color-labels', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ labels: newLabels, clientId: CLIENT_ID }),
    }).catch(() => {});
  }, []);

  useEffect(() => {
    if (!supabaseReady || !supabaseClient) return;
    const channel = supabaseClient
      .channel('app_state_changes')
      .on('postgres_changes', {
        event: 'UPDATE',
        schema: 'public',
        table: 'app_state',
      }, (payload) => {
        if (Date.now() - lastLocalChangeAt.current < 1500) return;
        const { parcels, color_labels } = payload.new;
        if (parcels)      setOverrides(parcels);
        if (color_labels) setColorLabels(prev => ({ ...prev, ...color_labels }));
      })
      .subscribe();

    return () => supabaseClient.removeChannel(channel);
  }, [supabaseReady]);

  if (!data) {
    return (
      <div style={appStyles.loading}>
        <div style={appStyles.loadingMark}>
          <i data-lucide="map" style={{ width: 32, height: 32, color: '#2F7D4F' }}></i>
        </div>
        <div style={appStyles.loadingText}>지적편집도 불러오는 중…</div>
      </div>
    );
  }

  const selectedParcel = selected
    ? data.parcels.find(p => p.id === selected)
    : null;
  const selectedOverride = selected ? overrides[selected] : null;

  const coloredCount = Object.values(overrides).filter(o => o.color).length;

  return (
    <div style={appStyles.root}>
      <TopBar
        coloredCount={coloredCount}
        totalCount={data.parcels.length}
        onMenu={() => setView('menu')}
      />
      <MapView
        data={data}
        overrides={overrides}
        colorLabels={colorLabels}
        selected={selected}
        onSelect={setSelected}
        fillOpacity={tweaks.fillOpacity}
      />

      {(() => {
        try {
          return selectedParcel ? (
            <ParcelSheet
              parcel={selectedParcel}
              override={selectedOverride}
              colorLabels={colorLabels}
              onChange={(patch) => updateOverride(selectedParcel.id, patch)}
              onClose={() => setSelected(null)}
            />
          ) : null;
        } catch (err) {
          console.error('[ParcelSheet 렌더 오류] ' + err.message + '\n' + err.stack);
          return null;
        }
      })()}

      {view === 'menu' && (
        <NavDrawer
          coloredCount={coloredCount}
          totalCount={data.parcels.length}
          lastSyncedAt={lastSyncedAt}
          onClose={() => setView(null)}
          onItem={(key) => setView(key)}
        />
      )}

      {view === 'settings' && (
        <SettingsSheet
          colorLabels={colorLabels}
          onChange={updateColorLabels}
          onClose={() => setView(null)}
        />
      )}

      {view === 'share' && (
        <ShareSheet
          coloredCount={coloredCount}
          lastSyncedAt={lastSyncedAt}
          onExport={exportJSON}
          onImport={importJSON}
          onClose={() => setView(null)}
        />
      )}

      <DebugPanel />
    </div>
  );
}

// ─── 상단 바 ───────────────────────────────
function TopBar({ coloredCount, totalCount, onMenu }) {
  return (
    <div style={appStyles.topBar}>
      <button style={appStyles.menuBtn} onClick={onMenu} aria-label="메뉴">
        <i data-lucide="menu" style={{ width: 22, height: 22, color: '#1A1814' }}></i>
      </button>
      <div style={appStyles.topBarMark}>
        <i data-lucide="map" style={{ width: 18, height: 18, color: '#2F7D4F' }}></i>
      </div>
      <div style={appStyles.topBarText}>
        <div style={appStyles.topBarTitle}>보구곶리 지적편집도</div>
        <div style={appStyles.topBarMeta}>
          <span style={{ color: '#2F7D4F', fontVariantNumeric: 'tabular-nums' }}>
            {coloredCount}
          </span>
          <span style={{ color: '#8E8B82' }}> / {totalCount} 지정</span>
        </div>
      </div>
    </div>
  );
}

// ─── 네비게이션 드로어 ───────────────────────
function NavDrawer({ coloredCount, totalCount, lastSyncedAt, onClose, onItem }) {
  const items = [
    { key: 'settings', icon: 'sliders-horizontal', title: '색상 이름 설정', desc: '6개 색상 라벨 편집' },
    { key: 'share',    icon: 'share-2',            title: '공유 / 동기화',   desc: 'JSON 파일로 주고받기' },
  ];
  const future = [
    { icon: 'list',           title: '지번 목록',     desc: '곧 제공' },
    { icon: 'filter',         title: '색상별 필터',   desc: '곧 제공' },
    { icon: 'file-down',      title: '이미지로 내보내기', desc: '곧 제공' },
  ];

  return (
    <React.Fragment>
      <div style={appStyles.drawerBackdrop} onClick={onClose}></div>
      <div style={appStyles.drawer}>
        <div style={appStyles.drawerHeader}>
          <div style={appStyles.drawerMark}>
            <i data-lucide="map" style={{ width: 18, height: 18, color: '#2F7D4F' }}></i>
          </div>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={appStyles.drawerTitle}>보구곶리 지적편집도</div>
            <div style={appStyles.drawerMeta}>
              <span style={{ color: '#2F7D4F', fontVariantNumeric: 'tabular-nums' }}>{coloredCount}</span>
              <span style={{ color: '#8E8B82' }}> / {totalCount} 지정</span>
              {lastSyncedAt && (
                <span style={{ color: '#8E8B82' }}>
                  {' · '}동기화 {lastSyncedAt.toLocaleTimeString('ko', { hour: '2-digit', minute: '2-digit' })}
                </span>
              )}
            </div>
          </div>
          <button style={appStyles.drawerClose} onClick={onClose} aria-label="닫기">
            <i data-lucide="x" style={{ width: 18, height: 18, color: '#5C5851' }}></i>
          </button>
        </div>

        <div style={appStyles.drawerSectionLabel}>도구</div>
        <div style={appStyles.drawerList}>
          {items.map(it => (
            <button key={it.key} style={appStyles.drawerItem} onClick={() => onItem(it.key)}>
              <div style={appStyles.drawerItemIcon}>
                <i data-lucide={it.icon} style={{ width: 18, height: 18, color: '#1A1814' }}></i>
              </div>
              <div style={{ flex: 1, minWidth: 0, textAlign: 'left' }}>
                <div style={appStyles.drawerItemTitle}>{it.title}</div>
                <div style={appStyles.drawerItemDesc}>{it.desc}</div>
              </div>
              <i data-lucide="chevron-right" style={{ width: 16, height: 16, color: '#C9C4B6' }}></i>
            </button>
          ))}
        </div>

        <div style={appStyles.drawerSectionLabel}>예정</div>
        <div style={appStyles.drawerList}>
          {future.map((it, i) => (
            <div key={i} style={{ ...appStyles.drawerItem, opacity: 0.5, cursor: 'default' }}>
              <div style={appStyles.drawerItemIcon}>
                <i data-lucide={it.icon} style={{ width: 18, height: 18, color: '#8E8B82' }}></i>
              </div>
              <div style={{ flex: 1, minWidth: 0, textAlign: 'left' }}>
                <div style={appStyles.drawerItemTitle}>{it.title}</div>
                <div style={appStyles.drawerItemDesc}>{it.desc}</div>
              </div>
            </div>
          ))}
        </div>
      </div>
    </React.Fragment>
  );
}

// ─── 지도 뷰 ───────────────────────────────
function MapView({ data, overrides, colorLabels, selected, onSelect, fillOpacity }) {
  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);

  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 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;

    // ─ 폴리곤 레이어 ─
    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();
    };

    // 1차: 기본(미지정) 폴리곤
    for (const p of data.parcels) {
      const ov = overrides[p.id];
      if (ov && ov.color) continue;
      drawPath(p.poly);
      ctx.fillStyle = '#FFFFFF';
      ctx.fill();
      ctx.strokeStyle = '#C9C4B6';
      ctx.lineWidth = 0.6;
      ctx.stroke();
    }

    // 2차: 색상 지정된 폴리곤
    for (const p of data.parcels) {
      const ov = overrides[p.id];
      if (!ov || !ov.color) continue;
      const colorObj = COLOR_BY_ID[ov.color];
      if (!colorObj) continue;
      const style = ov.style || 'fill';
      drawPath(p.poly);
      if (style === 'fill') {
        ctx.fillStyle = hexA(colorObj.value, fillOpacity);
        ctx.fill();
        ctx.strokeStyle = colorObj.value;
        ctx.lineWidth = 1.4;
        ctx.stroke();
      } else {
        // 테두리만
        ctx.fillStyle = '#FFFFFF';
        ctx.fill();
        ctx.strokeStyle = colorObj.value;
        ctx.lineWidth = 2.6;
        ctx.stroke();
      }
    }

    // 3차: 선택 강조
    if (selected) {
      const p = data.parcels.find(x => x.id === selected);
      if (p) {
        drawPath(p.poly);
        const ov = overrides[p.id];
        const colorObj = ov && ov.color ? COLOR_BY_ID[ov.color] : null;
        const style = ov && ov.style ? ov.style : 'fill';
        if (colorObj && style === 'fill') {
          ctx.fillStyle = hexA(colorObj.value, 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();
      }
    }
    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;

    for (const p of data.parcels) {
      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;

      // 폴리곤의 화면상 bbox
      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 = 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)';
      const fill = isCustom ? '#1A1814'
        : isColored ? '#3A3631'
        : '#5C5851';
      lctx.fillStyle = fill;

      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);
      }
    }
    lctx.restore();
  }, [data, overrides, colorLabels, selected, fillOpacity, transformVersion]);

  // ─ 제스처 ─
  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) {
      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];
      // 절대 좌표 기준으로 이동 거리 계산 — 모바일 URL 바 hide 시 컨테이너 rect가
      // 변하더라도 실제 손가락 이동량을 정확히 측정하기 위함
      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;
      }
      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}) curScale=${transformRef.current.scale.toFixed(1)} startScale=${g.startTransform.scale.toFixed(1)}`);
        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();
    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);
  };

  return (
    <div
      ref={containerRef}
      style={appStyles.mapContainer}
      onTouchStart={onPointerStart}
      onTouchMove={onPointerMove}
      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}
      onWheel={onWheel}
    >
      <canvas ref={canvasRef} style={appStyles.mapCanvas} />
      <canvas ref={labelCanvasRef} style={appStyles.mapCanvas} />

      <div style={appStyles.zoomControls}>
        <button style={appStyles.zoomBtn} onClick={() => zoom(1.6)} aria-label="확대">
          <i data-lucide="plus" style={{ width: 20, height: 20 }}></i>
        </button>
        <div style={appStyles.zoomDivider}></div>
        <button style={appStyles.zoomBtn} onClick={() => zoom(1 / 1.6)} aria-label="축소">
          <i data-lucide="minus" style={{ width: 20, height: 20 }}></i>
        </button>
      </div>
    </div>
  );
}

// ─── 바텀시트: 지번 선택 ─────────────────────
function ParcelSheet({ parcel, override, colorLabels, onChange, onClose }) {
  const [editing, setEditing] = useState(false);
  const [draftName, setDraftName] = useState('');
  const inputRef = useRef(null);
  const openedAtRef = useRef(Date.now());

  useEffect(() => {
    console.log(`[ParcelSheet] 마운트: ${parcel && parcel.jibun}`);
    return () => console.log('[ParcelSheet] 언마운트');
  }, []);

  const currentName = (override && override.name) || parcel.jibun || '이름 없음';
  const currentColor = override && override.color;
  const currentStyle = (override && override.style) || 'fill';

  useEffect(() => {
    setEditing(false);
    setDraftName('');
  }, [parcel.id]);

  const startEdit = () => {
    setDraftName((override && override.name) || '');
    setEditing(true);
    setTimeout(() => inputRef.current && inputRef.current.focus(), 50);
  };

  const commitName = () => {
    const v = draftName.trim();
    if (v) onChange({ name: v });
    else onChange({ name: '' });
    setEditing(false);
  };

  return (
    <React.Fragment>
      <div
        style={appStyles.sheetBackdrop}
        onTouchEnd={(e) => { e.preventDefault(); onClose(); }}
        onClick={() => { if (Date.now() - openedAtRef.current > 500) onClose(); }}
      ></div>
      <div style={appStyles.sheet}>
        <div style={appStyles.sheetGrip}></div>

        <div style={appStyles.sheetHeader}>
          <div style={appStyles.sheetMeta}>지번</div>
          {editing ? (
            <div style={appStyles.nameRow}>
              <input
                ref={inputRef}
                value={draftName}
                onChange={(e) => setDraftName(e.target.value)}
                onKeyDown={(e) => {
                  if (e.key === 'Enter') commitName();
                  if (e.key === 'Escape') setEditing(false);
                }}
                placeholder={parcel.jibun || '이름 입력'}
                style={appStyles.nameInput}
              />
              <button style={appStyles.nameOk} onClick={commitName}>저장</button>
            </div>
          ) : (
            <div style={appStyles.nameRow}>
              <div style={appStyles.nameView} onClick={startEdit}>
                <span style={appStyles.nameText}>{currentName}</span>
                <i data-lucide="pencil" style={{ width: 16, height: 16, color: '#8E8B82', flexShrink: 0 }}></i>
              </div>
            </div>
          )}
          {override && override.name && !editing && (
            <div style={appStyles.subMeta}>
              기본 지번: <span style={{ fontFamily: 'IBM Plex Mono, monospace' }}>{parcel.jibun}</span>
            </div>
          )}
        </div>

        {/* 스타일 (테두리 / 채움) */}
        <div style={appStyles.section}>
          <div style={appStyles.sectionLabel}>표시 방식</div>
          <div style={appStyles.styleRow}>
            {STYLE_OPTIONS.map(s => {
              const active = currentStyle === s.id;
              const disabled = !currentColor;
              return (
                <button
                  key={s.id}
                  disabled={disabled}
                  style={{
                    ...appStyles.styleBtn,
                    ...(active ? appStyles.styleBtnActive : {}),
                    ...(disabled ? appStyles.styleBtnDisabled : {}),
                  }}
                  onClick={() => onChange({ style: s.id })}
                >
                  <StylePreview style={s.id} color={currentColor ? COLOR_BY_ID[currentColor].value : '#C9C4B6'} active={active}/>
                  <span style={appStyles.styleLabel}>{s.label}</span>
                </button>
              );
            })}
          </div>
        </div>

        {/* 색상 */}
        <div style={appStyles.section}>
          <div style={appStyles.sectionLabel}>색상</div>
          <div style={appStyles.swatchGrid}>
            <button
              style={{
                ...appStyles.swatch,
                ...(currentColor == null ? appStyles.swatchActive : {}),
              }}
              onClick={() => onChange({ color: null })}
            >
              <div style={appStyles.swatchClear}>
                <i data-lucide="x" style={{ width: 16, height: 16, color: '#8E8B82' }}></i>
              </div>
              <span style={appStyles.swatchLabel}>없음</span>
            </button>
            {COLORS.map(c => {
              const active = currentColor === c.id;
              return (
                <button
                  key={c.id}
                  style={{
                    ...appStyles.swatch,
                    ...(active ? appStyles.swatchActive : {}),
                  }}
                  onClick={() => {
                    // 색상 변경 시, 기존 스타일 유지 (없으면 fill 기본)
                    onChange({ color: c.id, style: currentStyle });
                  }}
                >
                  <div style={{
                    ...appStyles.swatchChip,
                    background: hexA(c.value, 0.55),
                    borderColor: c.value,
                  }}></div>
                  <span style={appStyles.swatchLabel}>{colorLabels[c.id]}</span>
                </button>
              );
            })}
          </div>
        </div>

        <button style={appStyles.closeBtn} onClick={onClose}>닫기</button>
      </div>
    </React.Fragment>
  );
}

function StylePreview({ style, color, active }) {
  if (style === 'fill') {
    return (
      <div style={{
        width: 36, height: 24, borderRadius: 6,
        background: hexA(color, 0.55),
        border: `1.5px solid ${color}`,
      }}></div>
    );
  }
  return (
    <div style={{
      width: 36, height: 24, borderRadius: 6,
      background: '#FFFFFF',
      border: `2.5px solid ${color}`,
    }}></div>
  );
}

// ─── 설정: 색상 이름 편집 ────────────────────
function SettingsSheet({ colorLabels, onChange, onClose }) {
  const [drafts, setDrafts] = useState(colorLabels);
  useEffect(() => setDrafts(colorLabels), [colorLabels]);

  const commit = () => {
    onChange(drafts);
    onClose();
  };
  const reset = () => {
    setDrafts(Object.fromEntries(COLORS.map(c => [c.id, c.defaultLabel])));
  };

  return (
    <React.Fragment>
      <div style={appStyles.sheetBackdrop} onClick={onClose}></div>
      <div style={appStyles.sheet}>
        <div style={appStyles.sheetGrip}></div>

        <div style={appStyles.sheetHeader}>
          <div style={appStyles.sheetMeta}>설정</div>
          <div style={{ ...appStyles.nameText, fontSize: 18 }}>색상 이름</div>
          <div style={appStyles.subMeta}>각 색상이 의미하는 분류 이름을 직접 정합니다.</div>
        </div>

        <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
          {COLORS.map(c => (
            <div key={c.id} style={appStyles.colorEditRow}>
              <div style={{
                width: 28, height: 28, borderRadius: 8,
                background: hexA(c.value, 0.55),
                border: `1.5px solid ${c.value}`,
                flexShrink: 0,
              }}></div>
              <input
                value={drafts[c.id] || ''}
                placeholder={c.defaultLabel}
                onChange={(e) => setDrafts(prev => ({ ...prev, [c.id]: e.target.value }))}
                style={appStyles.colorEditInput}
                maxLength={12}
              />
            </div>
          ))}
        </div>

        <div style={{ display: 'flex', gap: 8 }}>
          <button style={{ ...appStyles.closeBtn, flex: 1 }} onClick={reset}>기본값</button>
          <button style={{
            ...appStyles.closeBtn, flex: 2,
            background: '#2F7D4F', color: '#FFFFFF', border: '1px solid #2F7D4F',
          }} onClick={commit}>저장</button>
        </div>
      </div>
    </React.Fragment>
  );
}

// ─── 공유: JSON 내보내기 / 불러오기 ──────────
function ShareSheet({ coloredCount, lastSyncedAt, onExport, onImport, onClose }) {
  const fileRef = useRef(null);

  return (
    <React.Fragment>
      <div style={appStyles.sheetBackdrop} onClick={onClose}></div>
      <div style={appStyles.sheet}>
        <div style={appStyles.sheetGrip}></div>

        <div style={appStyles.sheetHeader}>
          <div style={appStyles.sheetMeta}>공유</div>
          <div style={{ ...appStyles.nameText, fontSize: 18 }}>JSON 파일로 동기화</div>
          <div style={appStyles.subMeta}>
            현재 색상·이름·표시방식을 JSON 한 파일로 주고받습니다.
            카카오톡·이메일·드라이브로 공유 후 다른 사람이 불러오면 동일한 상태가 됩니다.
          </div>
        </div>

        <div style={appStyles.statRow}>
          <div style={appStyles.statBlock}>
            <div style={appStyles.statValue}>{coloredCount}</div>
            <div style={appStyles.statLabel}>지정된 지번</div>
          </div>
          <div style={appStyles.statBlock}>
            <div style={{ ...appStyles.statValue, fontSize: 14 }}>
              {lastSyncedAt
                ? lastSyncedAt.toLocaleTimeString('ko', { hour: '2-digit', minute: '2-digit' })
                : '—'}
            </div>
            <div style={appStyles.statLabel}>마지막 동기화</div>
          </div>
        </div>

        <button style={{
          ...appStyles.closeBtn, height: 52,
          background: '#2F7D4F', color: '#FFFFFF', border: '1px solid #2F7D4F',
          display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
        }} onClick={onExport}>
          <i data-lucide="download" style={{ width: 18, height: 18 }}></i>
          <span>JSON 내보내기</span>
        </button>

        <input
          ref={fileRef}
          type="file"
          accept=".json,application/json"
          style={{ display: 'none' }}
          onChange={(e) => {
            const f = e.target.files && e.target.files[0];
            if (f) onImport(f);
            e.target.value = '';
          }}
        />
        <button style={{
          ...appStyles.closeBtn, height: 52,
          display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
        }} onClick={() => fileRef.current && fileRef.current.click()}>
          <i data-lucide="upload" style={{ width: 18, height: 18, color: '#5C5851' }}></i>
          <span>JSON 불러오기</span>
        </button>

        <div style={{
          padding: 12, background: '#FBFAF6', border: '1px solid #E4E2DA',
          borderRadius: 10, fontSize: 12, lineHeight: 1.5, color: '#5C5851',
        }}>
          <strong style={{ color: '#1A1814' }}>3명 실시간 동시 편집</strong>이 필요하면
          작은 공유 서버(Firebase 등)를 붙여야 합니다. 필요하시면 알려주세요.
        </div>

        <button style={appStyles.closeBtn} onClick={onClose}>닫기</button>
      </div>
    </React.Fragment>
  );
}

// ─── 유틸 ─────────────────────────────────
// 글자 단위 줄바꿈 (한글/숫자 혼합 대응)
function wrapText(ctx, text, maxWidth, maxLines) {
  const lines = [];
  let cur = '';
  for (const ch of text) {
    const next = cur + ch;
    if (ctx.measureText(next).width <= maxWidth) {
      cur = next;
    } else {
      if (cur) lines.push(cur);
      else {
        // 한 글자도 안 들어감 → 표시 불가
        return [];
      }
      cur = ch;
      if (lines.length >= maxLines) {
        // 너무 길면 마지막 줄 말줄임
        const last = lines[maxLines - 1];
        let trimmed = last;
        while (trimmed.length > 0 && ctx.measureText(trimmed + '…').width > maxWidth) {
          trimmed = trimmed.slice(0, -1);
        }
        lines[maxLines - 1] = (trimmed || last) + '…';
        return lines.slice(0, maxLines);
      }
    }
  }
  if (cur) lines.push(cur);
  return lines.slice(0, maxLines);
}

function hexA(hex, a) {
  const h = hex.replace('#', '');
  const r = parseInt(h.slice(0, 2), 16);
  const g = parseInt(h.slice(2, 4), 16);
  const b = parseInt(h.slice(4, 6), 16);
  return `rgba(${r},${g},${b},${a})`;
}

// ─── 스타일 ───────────────────────────────
const appStyles = {
  root: {
    position: 'absolute', inset: 0,
    background: '#F6F4EE',
    fontFamily: 'Pretendard, -apple-system, system-ui, sans-serif',
    color: '#1A1814',
    display: 'flex', flexDirection: 'column', overflow: 'hidden',
  },
  loading: {
    position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
    alignItems: 'center', justifyContent: 'center', gap: 16,
    background: '#F6F4EE',
    fontFamily: 'Pretendard, system-ui, sans-serif',
  },
  loadingMark: {
    width: 56, height: 56, borderRadius: 14,
    background: '#FFFFFF', border: '1px solid #E4E2DA',
    display: 'flex', alignItems: 'center', justifyContent: 'center',
  },
  loadingText: { color: '#5C5851', fontSize: 14 },

  topBar: {
    flexShrink: 0,
    height: 56, padding: '0 12px 0 16px',
    display: 'flex', alignItems: 'center', gap: 12,
    background: '#FFFFFF',
    borderBottom: '1px solid #E4E2DA', zIndex: 5,
  },
  topBarMark: {
    width: 32, height: 32, borderRadius: 8,
    background: 'rgba(47, 125, 79, 0.08)',
    display: 'flex', alignItems: 'center', justifyContent: 'center',
    flexShrink: 0,
  },
  topBarText: {
    flex: 1, display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0,
  },
  topBarTitle: {
    fontSize: 15, fontWeight: 600, letterSpacing: '-0.01em', color: '#1A1814',
  },
  topBarMeta: {
    fontSize: 11, fontWeight: 500, fontVariantNumeric: 'tabular-nums',
  },
  iconBtn: {
    width: 40, height: 40, borderRadius: 10,
    border: '1px solid #E4E2DA', background: '#FFFFFF',
    display: 'flex', alignItems: 'center', justifyContent: 'center',
    cursor: 'pointer', padding: 0,
  },
  iconBtnAlert: {
    background: '#2F7D4F', borderColor: '#2F7D4F',
  },
  menuBtn: {
    position: 'relative',
    width: 40, height: 40, borderRadius: 10,
    border: '1px solid #E4E2DA', background: '#FFFFFF',
    display: 'flex', alignItems: 'center', justifyContent: 'center',
    cursor: 'pointer', padding: 0, marginRight: 4,
  },
  menuDot: {
    position: 'absolute', top: 8, right: 8,
    width: 8, height: 8, borderRadius: 999,
    background: '#2F7D4F',
    boxShadow: '0 0 0 2px #FFFFFF',
  },

  drawerBackdrop: {
    position: 'absolute', inset: 0,
    background: 'rgba(0,0,0,0.32)', zIndex: 11,
    animation: 'fadeIn 180ms cubic-bezier(0.2,0,0.1,1)',
  },
  drawer: {
    position: 'absolute', left: 0, top: 0, bottom: 0,
    width: 'min(86vw, 320px)',
    background: '#FFFFFF',
    zIndex: 12,
    boxShadow: '4px 0 24px rgba(0,0,0,0.08)',
    animation: 'slideRight 240ms cubic-bezier(0.2,0,0.1,1)',
    display: 'flex', flexDirection: 'column',
    padding: '14px 14px 20px',
    gap: 12,
    overflowY: 'auto',
  },
  drawerHeader: {
    display: 'flex', alignItems: 'center', gap: 10,
    paddingBottom: 12, borderBottom: '1px solid #E4E2DA',
  },
  drawerMark: {
    width: 32, height: 32, borderRadius: 8,
    background: 'rgba(47, 125, 79, 0.08)',
    display: 'flex', alignItems: 'center', justifyContent: 'center',
    flexShrink: 0,
  },
  drawerTitle: {
    fontSize: 14, fontWeight: 600, letterSpacing: '-0.01em', color: '#1A1814',
  },
  drawerMeta: {
    fontSize: 11, fontWeight: 500, marginTop: 2,
  },
  drawerClose: {
    width: 32, height: 32, borderRadius: 8,
    border: '1px solid #E4E2DA', background: '#FFFFFF',
    display: 'flex', alignItems: 'center', justifyContent: 'center',
    cursor: 'pointer', padding: 0, flexShrink: 0,
  },
  drawerSectionLabel: {
    fontSize: 10, fontWeight: 600, color: '#8E8B82',
    letterSpacing: '0.06em', textTransform: 'uppercase',
    padding: '4px 4px 0',
  },
  drawerList: {
    display: 'flex', flexDirection: 'column', gap: 4,
  },
  drawerItem: {
    display: 'flex', alignItems: 'center', gap: 12,
    padding: '10px 10px',
    background: '#FFFFFF', border: '1px solid #E4E2DA',
    borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
    width: '100%',
  },
  drawerItemIcon: {
    width: 32, height: 32, borderRadius: 8,
    background: '#FBFAF6',
    display: 'flex', alignItems: 'center', justifyContent: 'center',
    flexShrink: 0,
  },
  drawerItemTitle: {
    fontSize: 14, fontWeight: 600, color: '#1A1814',
    overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
  },
  drawerItemDesc: {
    fontSize: 11, color: '#8E8B82', marginTop: 1,
    overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
  },

  mapContainer: {
    flex: 1, position: 'relative', touchAction: 'none',
    overflow: 'hidden', background: '#FBFAF6', cursor: 'grab',
  },
  mapCanvas: {
    position: 'absolute', inset: 0, width: '100%', height: '100%',
    pointerEvents: 'none',
  },
  zoomControls: {
    position: 'absolute', right: 12, bottom: 12,
    background: '#FFFFFF', border: '1px solid #E4E2DA',
    borderRadius: 10, overflow: 'hidden',
    boxShadow: '0 1px 3px rgba(0,0,0,0.05), 0 4px 12px rgba(0,0,0,0.04)',
    display: 'flex', flexDirection: 'column',
  },
  zoomBtn: {
    width: 44, height: 44, border: 'none', background: '#FFFFFF',
    color: '#1A1814',
    display: 'flex', alignItems: 'center', justifyContent: 'center',
    cursor: 'pointer', padding: 0,
  },
  zoomDivider: { height: 1, background: '#E4E2DA' },

  sheetBackdrop: {
    position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.32)',
    zIndex: 9,
    animation: 'fadeIn 180ms cubic-bezier(0.2,0,0.1,1)',
  },
  sheet: {
    position: 'absolute', left: 0, right: 0, bottom: 0,
    background: '#FFFFFF', borderRadius: '20px 20px 0 0',
    padding: '8px 20px 20px', zIndex: 10,
    boxShadow: '0 -8px 24px rgba(0,0,0,0.08)',
    animation: 'slideUp 240ms cubic-bezier(0.2,0,0.1,1)',
    display: 'flex', flexDirection: 'column', gap: 16,
    maxHeight: '85vh', overflowY: 'auto',
  },
  sheetGrip: {
    width: 36, height: 4, background: '#D4D0C5', borderRadius: 999,
    margin: '4px auto 4px',
  },

  sheetHeader: { display: 'flex', flexDirection: 'column', gap: 6 },
  sheetMeta: {
    fontSize: 11, fontWeight: 600, color: '#8E8B82',
    letterSpacing: '0.04em', textTransform: 'uppercase',
  },
  nameRow: { display: 'flex', alignItems: 'center', gap: 8 },
  nameView: {
    flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
    gap: 12,
    padding: '12px 14px',
    background: '#FBFAF6', border: '1px solid #E4E2DA',
    borderRadius: 10, cursor: 'text', minHeight: 48,
  },
  nameText: {
    fontSize: 20, fontWeight: 600, letterSpacing: '-0.01em', color: '#1A1814',
    fontFamily: 'Pretendard, IBM Plex Mono, monospace',
    overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
  },
  nameInput: {
    flex: 1, padding: '12px 14px',
    fontSize: 18, fontWeight: 600, letterSpacing: '-0.01em',
    background: '#FFFFFF', border: '2px solid #2F7D4F', borderRadius: 10,
    outline: 'none', minHeight: 48, fontFamily: 'inherit', color: '#1A1814',
  },
  nameOk: {
    flexShrink: 0, padding: '0 18px', height: 48,
    background: '#2F7D4F', color: '#FFFFFF',
    border: 'none', borderRadius: 10,
    fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
  },
  subMeta: { fontSize: 12, color: '#8E8B82', paddingLeft: 2 },

  section: { display: 'flex', flexDirection: 'column', gap: 10 },
  sectionLabel: {
    fontSize: 11, fontWeight: 600, color: '#8E8B82',
    letterSpacing: '0.04em', textTransform: 'uppercase',
  },

  styleRow: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 },
  styleBtn: {
    display: 'flex', alignItems: 'center', gap: 12,
    padding: '12px 14px',
    background: '#FFFFFF', border: '1px solid #E4E2DA',
    borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
    transition: 'border-color 120ms, background 120ms',
  },
  styleBtnActive: {
    borderColor: '#2F7D4F', borderWidth: 2, padding: '11px 13px',
    background: 'rgba(47, 125, 79, 0.04)',
  },
  styleBtnDisabled: { opacity: 0.4, cursor: 'not-allowed' },
  styleLabel: { fontSize: 14, fontWeight: 600, color: '#1A1814' },

  swatchGrid: {
    display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8,
  },
  swatch: {
    display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
    padding: '10px 4px 8px',
    background: '#FFFFFF', border: '1px solid #E4E2DA', borderRadius: 10,
    cursor: 'pointer', fontFamily: 'inherit',
    transition: 'border-color 120ms, background 120ms',
  },
  swatchActive: {
    borderColor: '#2F7D4F', borderWidth: 2, padding: '9px 3px 7px',
    background: 'rgba(47, 125, 79, 0.04)',
  },
  swatchChip: { width: 28, height: 28, borderRadius: 8, border: '1.5px solid' },
  swatchClear: {
    width: 28, height: 28, borderRadius: 8,
    background: '#FBFAF6', border: '1.5px dashed #C9C4B6',
    display: 'flex', alignItems: 'center', justifyContent: 'center',
  },
  swatchLabel: {
    fontSize: 11, fontWeight: 500, color: '#5C5851',
    overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
    maxWidth: '100%',
  },

  closeBtn: {
    height: 48, background: '#F6F4EE', color: '#1A1814',
    border: '1px solid #E4E2DA', borderRadius: 10,
    fontSize: 14, fontWeight: 600, fontFamily: 'inherit', cursor: 'pointer',
  },

  colorEditRow: {
    display: 'flex', alignItems: 'center', gap: 12,
    padding: '8px 12px',
    background: '#FBFAF6', border: '1px solid #E4E2DA', borderRadius: 10,
  },
  colorEditInput: {
    flex: 1, padding: '10px 12px',
    fontSize: 15, fontWeight: 500,
    background: '#FFFFFF', border: '1px solid #E4E2DA', borderRadius: 8,
    outline: 'none', fontFamily: 'inherit', color: '#1A1814',
    minHeight: 40,
  },

  statRow: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 },
  statBlock: {
    padding: 12, background: '#FBFAF6',
    border: '1px solid #E4E2DA', borderRadius: 10,
    display: 'flex', flexDirection: 'column', gap: 2,
  },
  statValue: {
    fontSize: 22, fontWeight: 600, letterSpacing: '-0.01em',
    fontVariantNumeric: 'tabular-nums', color: '#1A1814',
  },
  statLabel: { fontSize: 11, color: '#8E8B82', fontWeight: 500 },
};

// ─── 디버그 패널 ────────────────────────────
function DebugPanel() {
  const [open, setOpen] = useState(false);
  const [tick, setTick] = useState(0);
  const listRef = useRef(null);

  useEffect(() => {
    const cb = () => setTick(n => n + 1);
    _dbgListeners.add(cb);
    return () => _dbgListeners.delete(cb);
  }, []);

  useEffect(() => {
    if (open && listRef.current) {
      listRef.current.scrollTop = listRef.current.scrollHeight;
    }
  }, [open, tick]);

  return (
    <>
      <button
        onClick={() => setOpen(o => !o)}
        style={{
          position: 'absolute', bottom: 80, left: 12, zIndex: 50,
          width: 44, height: 26, borderRadius: 6,
          background: open ? '#1A1814' : 'rgba(26,24,20,0.65)',
          color: '#fff', border: 'none', fontSize: 11, fontWeight: 700,
          fontFamily: 'IBM Plex Mono, monospace', cursor: 'pointer',
          letterSpacing: '0.04em',
        }}
      >DBG</button>
      {open && (
        <div style={{
          position: 'absolute', inset: '56px 0 0 0', zIndex: 49,
          background: 'rgba(10,9,8,0.93)',
          display: 'flex', flexDirection: 'column',
        }}>
          <div style={{
            display: 'flex', alignItems: 'center', justifyContent: 'space-between',
            padding: '5px 10px', borderBottom: '1px solid #2a2a2a', flexShrink: 0,
          }}>
            <span style={{ color: '#888', fontSize: 11, fontFamily: 'IBM Plex Mono, monospace' }}>
              로그 {_dbgLogs.length}건
            </span>
            <button
              onClick={() => { _dbgLogs.length = 0; setTick(n => n + 1); }}
              style={{
                background: 'none', border: '1px solid #333', borderRadius: 4,
                color: '#888', fontSize: 11, padding: '2px 8px', cursor: 'pointer',
                fontFamily: 'inherit',
              }}
            >지우기</button>
          </div>
          <div ref={listRef} style={{ flex: 1, overflow: 'auto', padding: '6px 10px' }}>
            {_dbgLogs.map((log, i) => (
              <div key={i} style={{
                color: log.level === 'error' ? '#ff6b6b' : log.level === 'warn' ? '#ffd93d' : '#9affb0',
                fontSize: 11, fontFamily: 'IBM Plex Mono, monospace',
                lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
                borderBottom: i < _dbgLogs.length - 1 ? '1px solid #1c1c1c' : 'none',
                paddingBottom: 2,
              }}>
                {log.text}
              </div>
            ))}
          </div>
        </div>
      )}
    </>
  );
}

window.App = App;
