diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index a7945ca0..8e691cb0 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -31,6 +31,36 @@ export { const SVG_NS = "http://www.w3.org/2000/svg"; +/** 넘침 정리 한 장 몫 — 쓰기·읽기·쓰기 세 토막으로 갈라 두어 프레임 단위로 묶는다. */ +interface ReflowCard { + reset(): void; + /** 패널로 옮길 개수. -1 이면 아직 자리를 안 잡아 건드리지 않는다. */ + measure(): number; + apply(moveCount: number): void; +} + +const reflowQueue = new Set(); +let reflowScheduled = false; + +/** + * 넘침 정리를 **한 프레임에 몰아** 돌린다 — 모든 카드의 쓰기를 먼저 끝내고, 그 다음 + * 읽기를 몰아서 하고, 마지막에 쓰기를 몰아서 한다. 강제 레이아웃이 카드 수만큼(67회) + * 나던 것이 프레임당 한 번으로 줄어든다. + */ +function scheduleReflow(card: ReflowCard): void { + reflowQueue.add(card); + if (reflowScheduled) return; + reflowScheduled = true; + requestAnimationFrame(() => { + reflowScheduled = false; + const cards = [...reflowQueue]; + reflowQueue.clear(); + for (const item of cards) item.reset(); + const counts = cards.map((item) => item.measure()); + cards.forEach((item, index) => item.apply(counts[index])); + }); +} + /** 카드 버튼줄의 flex 간격(px) — 모든 카드가 같은 CSS 를 쓰므로 한 번만 잰다. * `getComputedStyle` 도 강제 레이아웃을 부르므로 카드 67장마다 부르지 않는다. */ let barGapPx: number | null = null; @@ -416,50 +446,52 @@ export function buildDesignControls( more.append(moreSummary, morePanel); bar.append(slopeDirSeg, ...moveable, more); - const reflow = (): void => { - // 후보 전부 인라인 복귀 → 폭을 **한 번만 재고** → 넘치는 만큼 뒤에서부터 패널로 이동. - // - // 예전에는 한 칸 옮길 때마다 `bar.scrollWidth` 를 다시 읽어(쓰기→읽기→쓰기) 브라우저가 - // 매번 레이아웃을 강제로 다시 계산했다. 카드 67장마다 도는 자리라 B06 진입에서 일한 - // 시간의 19.4% 를 이 함수가 썼다(2026-09-06 CPU 프로파일). 읽기와 쓰기를 갈랐다. - for (const element of moveable) bar.insertBefore(element, more); - morePanel.replaceChildren(); - more.hidden = false; // 폭을 재려면 자리에 있어야 한다. - - const clientWidth = bar.clientWidth; - if (clientWidth <= 0) { - more.hidden = true; - return; - } - if (barGapPx === null) barGapPx = Number.parseFloat(getComputedStyle(bar).gap) || 0; - const widths = moveable.map((element) => element.offsetWidth); - let overflow = bar.scrollWidth - clientWidth; - - let moveCount = 0; - while (overflow > 1 && moveCount < moveable.length) { - overflow -= widths[moveable.length - 1 - moveCount] + barGapPx; - moveCount += 1; - } - if (moveCount === 0) { - more.hidden = true; - return; - } - for (let index = 0; index < moveCount; index += 1) { - morePanel.insertBefore(moveable[moveable.length - 1 - index], morePanel.firstChild); - } + // 넘침 정리는 **세 토막**으로 나눈다 — 쓰기(reset) → 읽기(measure) → 쓰기(apply). + // 카드 안에서 한 장씩 하면 쓰기 뒤 읽기가 카드 수만큼 반복돼 브라우저가 레이아웃을 + // 그때마다 강제로 다시 잰다. 게다가 한 번의 강제 레이아웃이 **그때까지 들어간 카드 + // 전부**를 다시 재므로 뒤로 갈수록 비싸진다(2026-09-06 CPU 프로파일: 진입에서 자기 + // 시간 1위). 그래서 `scheduleReflow` 가 67장을 모아 한 프레임에 묶어 돌린다. + const card: ReflowCard = { + reset() { + for (const element of moveable) bar.insertBefore(element, more); + morePanel.replaceChildren(); + more.hidden = false; // 폭을 재려면 자리에 있어야 한다. + }, + measure() { + const clientWidth = bar.clientWidth; + if (clientWidth <= 0) return -1; // 아직 자리를 안 잡았다 — 그대로 둔다. + if (barGapPx === null) barGapPx = Number.parseFloat(getComputedStyle(bar).gap) || 0; + const widths = moveable.map((element) => element.offsetWidth); + let overflow = bar.scrollWidth - clientWidth; + let moveCount = 0; + while (overflow > 1 && moveCount < moveable.length) { + overflow -= widths[moveable.length - 1 - moveCount] + barGapPx; + moveCount += 1; + } + return moveCount; + }, + apply(moveCount) { + if (moveCount <= 0) { + more.hidden = true; + return; + } + for (let index = 0; index < moveCount; index += 1) { + morePanel.insertBefore(moveable[moveable.length - 1 - index], morePanel.firstChild); + } + }, }; - // 첫 호출은 아래 `requestAnimationFrame` 이 맡는다 — 관찰을 걸면 초기 크기로 곧바로 한 번 - // 더 불려 카드마다 reflow 가 두 번 돌았다. + // 첫 호출은 아래 `scheduleReflow` 가 맡는다 — 관찰을 걸면 초기 크기로 곧바로 한 번 더 + // 불려 카드마다 두 번 돌았다. 창 크기가 바뀔 때만 그 카드 하나를 다시 넣는다. let firstObservation = true; const overflowObserver = new ResizeObserver(() => { if (firstObservation) { firstObservation = false; return; } - reflow(); + scheduleReflow(card); }); overflowObserver.observe(bar); - requestAnimationFrame(reflow); + scheduleReflow(card); // 암 경계선 제어는 그래프 X축 제목 행으로 이동(E-7, Cross_View에서 배치). // 절·성토 면적 readout은 그래프 중상단 오버레이로 이동(E-4, Cross_View에서 배치).