perf(B05,B06): 지형 표고를 레이캐스팅 대신 격자 색인으로 · 카드 버튼줄 레이아웃 스래싱 제거

B05 진입이 간헐적으로 13~14초 멎던 원인을 CDP 프로파일로 잡음. 13,537ms 중
getVertexPosition 3,267 + intersectTriangle 3,178 + checkGeometryIntersection 3,012
+ _computeIntersections 2,198 = 11.6초가 three.js 레이캐스팅이었음.

- terrainElevation 이 점마다 Raycaster 로 지형 높이를 찾고 있었음(한 번 쏠 때마다
  삼각형 전수 훑기). 마커·측점선이 점마다 부르는 자리라 곱해짐. 이미 있던
  TerrainHeightIndex(격자 색인)로 돌리고, 색인도 지형과 한 벌로 보관(빌드 O(삼각형)).
  같은 교훈을 2026-08-23 비탈 투영에서 겪어 색인을 만들어 뒀는데 이 함수만 옛 길이었음.
- B06 카드 버튼줄 reflow 의 레이아웃 스래싱 제거 — 한 칸 옮길 때마다 scrollWidth 를
  다시 읽어 매번 강제 레이아웃을 냈음(카드 67장마다). 읽기/쓰기를 갈라 폭을 한 번만
  재고 옮길 개수를 계산. ResizeObserver 첫 호출도 건너뜀(rAF 와 겹쳐 두 번 돌았음).
- get_section_context·get_section_detail 의 순차 읽기를 asyncio.gather 로 묶음
  (원격 DB 왕복 약 12ms/질의).

자체검증(공용 브라우저 4왕복) — B05 진입 14,708/1,060/14,841 -> 1,269/806/726/746ms.
급등 사라짐. B06 진입 2,884 -> 2,330~2,864ms(긴 작업 합 2,313 -> 1,853).
시험 400 통과·17 건너뜀, typecheck 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 22:22:08 +09:00
co-authored by Claude Opus 5
parent 2476af0f4a
commit 0b30f5b035
3 changed files with 90 additions and 35 deletions
+41 -11
View File
@@ -31,6 +31,10 @@ export {
const SVG_NS = "http://www.w3.org/2000/svg";
/** 카드 버튼줄의 flex 간격(px) — 모든 카드가 같은 CSS 를 쓰므로 한 번만 잰다.
* `getComputedStyle` 도 강제 레이아웃을 부르므로 카드 67장마다 부르지 않는다. */
let barGapPx: number | null = null;
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
@@ -413,21 +417,47 @@ export function buildDesignControls(
bar.append(slopeDirSeg, ...moveable, more);
const reflow = (): void => {
// 후보 전부 인라인 복귀 → more 숨김 → 넘치면 뒤에서부터 패널로 이동.
// 후보 전부 인라인 복귀 → 폭을 **한 번만 재고** → 넘치는 만큼 뒤에서부터 패널로 이동.
//
// 예전에는 한 칸 옮길 때마다 `bar.scrollWidth` 를 다시 읽어(쓰기→읽기→쓰기) 브라우저가
// 매번 레이아웃을 강제로 다시 계산했다. 카드 67장마다 도는 자리라 B06 진입에서 일한
// 시간의 19.4% 를 이 함수가 썼다(2026-09-06 CPU 프로파일). 읽기와 쓰기를 갈랐다.
for (const element of moveable) bar.insertBefore(element, more);
morePanel.replaceChildren();
more.hidden = true;
if (bar.clientWidth <= 0) return;
for (
let index = moveable.length - 1;
index >= 0 && bar.scrollWidth > bar.clientWidth + 1;
index -= 1
) {
more.hidden = false;
morePanel.insertBefore(moveable[index], morePanel.firstChild);
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);
}
};
const overflowObserver = new ResizeObserver(() => reflow());
// 첫 호출은 아래 `requestAnimationFrame` 이 맡는다 — 관찰을 걸면 초기 크기로 곧바로 한 번
// 더 불려 카드마다 reflow 가 두 번 돌았다.
let firstObservation = true;
const overflowObserver = new ResizeObserver(() => {
if (firstObservation) {
firstObservation = false;
return;
}
reflow();
});
overflowObserver.observe(bar);
requestAnimationFrame(reflow);