# AI 꾸러미 — 넘칠 때만 줄이기 (자동 축소) — Shrink-to-fit (overflow-only scaling)
> 패널마다 쓸 수 있는 자리를 정해 두고, 넘칠 때만 넘친 비율만큼 그 패널의 zoom 을 줄여 — 넘치지 않는 패널은 그대로 두고 잘림을 막는다.  
> 견본: https://ai-techstudio.web.app/#t/u70

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 넘칠 때만 줄이기 (자동 축소) — Shrink-to-fit (overflow-only scaling)

#### 1. 목표
게임 화면 오른쪽 정보 패널 · 아래 단추 줄에 「넘칠 때만 줄이기」를 넣어 줘 — 패널마다 쓸 수 있는 자리를 정하고, 넘치는 패널만 넘친 만큼 줄이고 나머지는 그대로. 분위기는 가로 폰(844×390)에서도 잘림 없이.

#### 2. 핵심 기술 용어
- **Shrink-to-fit (overflow-only scaling)** — 넘칠 때만 줄이기
- **CSS zoom** — 칸 하나를 통째로 줄이기 (자리도 함께 줄어듦)
- **MutationObserver · ResizeObserver** — 내용 · 크기가 바뀌면 다시 재기
- **Anchor-aware fitting** — 붙은 쪽(위 · 아래 · 가운데)은 그대로 두고 맞추기

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 브라우저 — PC · 폰 모두

#### 4. 조건
- 넘치지 않으면 손대지 않는다 (CSS 가 정한 크기 그대로)
- 잴 때마다 먼저 zoom 을 지우고 원래 크기에서 다시 잰다 (줄인 값에 또 줄이지 않게)
- 자기가 바꾼 style 이 다시 감시를 부르지 않게 — MutationObserver 는 style 속성을 보지 않는다
- 튀어나오는 연출(scale) 중 잰 크기는 작으니, animationend · transitionend 뒤에 다시 잰다

#### 5. 완성 기준 (이게 보이면 성공)
- 창 높이를 줄이면 넘치는 패널만 「× 0.82」 처럼 줄고, 나머지는 「그대로」
- 「모두 같은 비율로」 비교를 켜면 넘치지 않던 패널까지 작아지는 차이가 보인다
- 독일어처럼 긴 글에서도 단추 글자가 단추 밖으로 나가지 않는다
- 가로 폰 844×390 에서 플레이 · 나가기 단추가 잘리지 않는다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 패널마다 규칙 하나: 선택자 · 붙은 쪽(top · bottom · center) · 쓸 수 있는 범위(판 좌표 위 · 아래 · 왼 · 오른).
- 패널 크기를 재서 남는 높이 / 필요한 높이, 남는 폭 / 필요한 폭 중 작은 값 s 를 구한다. s ≥ 1 이면 그대로 둔다.
- 넘치면 zoom 을 s × 0.995 배 — zoom 은 자리 값(top · right)도 같이 바꿔 한 번에 딱 맞지 않으므로 세 번까지 다시 잰다.
- 단추 글자가 단추 밖으로 나가면 그 글자만 들어갈 때까지 4% 씩 줄이되, 원래의 60% 에서 멈춘다.
- 내용 · 클래스가 바뀌거나(MutationObserver) 크기가 바뀌면(ResizeObserver) 다음 프레임에 한 번 다시 맞춘다.

## 핵심 코드 — 패널 하나를 넘친 만큼만 줄이기
(발췌: shell/components/fitChrome.ts fitOne() 을 줄여 정리 (견본 demos/demosDraw2d.ts u70 은 같은 식을 그림으로))
```ts
interface Area { top: number; bottom: number; left: number; right: number }
// boxOf(root, el) = el 의 사각형을 판(1600×900) 좌표로 잰 값

function fitOne(root: HTMLElement, el: HTMLElement, anchor: 'top' | 'bottom', area: () => Area) {
  el.style.removeProperty('zoom');                      // 원래 크기에서 다시 잰다
  if (!el.getClientRects().length) return;
  let zoom = parseFloat(getComputedStyle(el).zoom) || 1;
  for (let i = 0; i < 3; i++) {                         // zoom 은 자리 값도 바꿔 여러 번 잰다
    const a = area();
    const b = boxOf(root, el);
    const sh = anchor === 'top'
      ? (a.bottom - Math.max(b.top, a.top)) / (b.bottom - b.top)
      : (Math.min(b.bottom, a.bottom) - a.top) / (b.bottom - b.top);
    const sw = (a.right - a.left) / (b.right - b.left);
    const s = Math.min(sh, sw);
    if (s >= 0.999) return;                             // 넘치지 않으면 그대로
    zoom *= s * 0.995;
    el.style.zoom = String(zoom);
  }
}

// 바뀔 때만, 한 프레임에 한 번
let raf = 0;
const schedule = () => { if (!raf) raf = requestAnimationFrame(() => { raf = 0; runAll(); }); };
new MutationObserver(schedule).observe(root, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['hidden', 'class'] });
new ResizeObserver(schedule).observe(root);
```

## 흔한 실수 · 확인 목록
- [ ] **같은 배율로 다 키웠더니 패널이 많은 게임에서 단추가 화면 밖으로 잘렸다** — 패널마다 쓸 수 있는 자리를 정하고 넘치는 만큼만 그 패널을 줄인다.
- [ ] **감시가 style 속성까지 보면 zoom 을 바꿀 때마다 다시 불려 끝없이 돈다** — attributeFilter 로 class · hidden 같은 것만 본다.
- [ ] **튀어나오는 연출 중에 재면 크기가 작게 잡혀 덜 줄인다** — animationend · transitionend 뒤에 다시 잰다.
- [ ] **긴 번역 글을 무작정 줄이면 읽을 수 없게 작아진다** — 원래 크기의 60%(아주 큰 글씨는 26px)에서 멈춘다.

## 완성 기준 체크리스트
- [ ] 창 높이를 줄이면 넘치는 패널만 「× 0.82」 처럼 줄고, 나머지는 「그대로」
- [ ] 「모두 같은 비율로」 비교를 켜면 넘치지 않던 패널까지 작아지는 차이가 보인다
- [ ] 독일어처럼 긴 글에서도 단추 글자가 단추 밖으로 나가지 않는다
- [ ] 가로 폰 844×390 에서 플레이 · 나가기 단추가 잘리지 않는다

## 이 기술 정보
- id: `u70` · 분류: 2D · 화면 › CSS · 화면 틀 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 바뀔 때만 패널 몇 개 · 단추 몇십 개를 잰다 (requestAnimationFrame 으로 한 프레임에 한 번).
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u70
- 쓰면 좋을 때: 게임마다 패널 내용 양이 달라 같은 크기로는 어떤 게임은 잘릴 때 / 번역 글 길이가 언어마다 1.5 ~ 2배 다를 때
- 쓰지 말 때: 모든 패널을 같은 비율로 한꺼번에 줄이기 — 넘치지 않는 패널까지 작아진다 / 매 프레임 재기 — 바뀔 때만 잰다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u70 견본 항목 — `src/demos/demosDraw2d.ts:1739`
```ts
  u70: {
    kind: '2d',
    caption: '창 높이가 줄면 넘치는 패널만 필요한 만큼 줄여요 — 위 숫자가 패널마다 다른 축소 비율',
    make() {
      const VW = 900;
      const PAN: [number, number, string, string][] = [
        [20, 210, '#ffcb7a', '미션'],
        [250, 400, '#7cc25a', '판'],
        [670, 210, '#7fbcff', '정보'],
      ];
      const NEED = [560, 400, 300];
      let all = false;
      return {
        draw(g, w, h, t) {
          const u = scaleOf(w, h);
          bg(g, w, h, '#2a3a6e', '#121a3a');
          const H = lerp(640, 300, swing(t, 0.7, 1.5));
          const s = Math.min((w * 0.9) / VW, (h * 0.66) / 640);
          const ox = (w - VW * s) / 2;
          const oy = h * 0.24;
          g.fillStyle = '#0b0d18';
          rr(g, ox - 4 * u, oy - 4 * u, VW * s + 8 * u, H * s + 8 * u, 6 * u);
          g.fill();
          g.fillStyle = '#e9eefc';
          g.fillRect(ox, oy, VW * s, H * s);
          const A = H - 40;
          const minAll = Math.min(...NEED.map((n) => Math.min(1, A / n)));
          for (let i = 0; i < 3; i++) {
            const [px, pw, col, name] = PAN[i]!;
            const need = NEED[i]!;
            const k = all ? minAll : Math.min(1, A / need);
            g.save();
            g.beginPath();
            g.rect(ox, oy, VW * s, H * s);
            g.clip();
            g.translate(ox + px * s, oy + 20 * s);
            g.scale(s * k, s * k);
            g.fillStyle = '#fff';
            rr(g, 0, 0, pw, need, 18);
            g.fill();
            g.strokeStyle = col;
            g.lineWidth = 6;
            g.stroke();
            g.fillStyle = col;
            rr(g, 0, 0, pw, 60, 18);
            g.fill();
            txt(g, name, pw / 2, 32, 34, '#fff', 'center', TF, 400);
            g.fillStyle = '#d5dcf0';
            const rows = Math.floor((need - 90) / 56);
            for (let r = 0; r < rows; r++) {
              rr(g, 20, 84 + r * 56, pw - 40, 40, 10);
              g.fill();
            }
            g.restore();
            const lx = ox + (px + pw / 2) * s;
            const shrunk = k < 0.999;
            pill(g, shrunk ? `× ${k.toFixed(2)}` : '그대로', lx, oy - 13 * u, 8 * u, shrunk ? '#ff6fa8' : '#2fbf71');
          }
          txt(g, `창 높이 ${Math.round(H)}`, w / 2, h * 0.06, 8.5 * u, '#ffe28a');
        },
        controls: [{ type: 'toggle', label: '비교: 모두 같은 비율로 줄이기', value: false, on: (v) => (all = v) }] as Control[],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [고정 크기 화면 확대](https://ai-techstudio.web.app/ai/t/u68.md) `u68`
- 다음에 해 볼 기술: [화면 방향 고정 · 노치 피하기](https://ai-techstudio.web.app/ai/t/u71.md) `u71` · [다국어 (원문을 번역 키로)](https://ai-techstudio.web.app/ai/t/u74.md) `u74`
